Compare commits

..

103 commits
v1.2.0 ... main

Author SHA1 Message Date
Flavien Solt
736107f7a4 Reject oversized step-repeat expansions 2026-04-25 12:04:51 +02:00
Flavien Solt
e3674de08d Accept valueless Gerber attributes 2026-04-25 12:04:44 +02:00
Flavien Solt
516a9d337f Improve Excellon compatibility parsing 2026-04-21 11:13:15 +08:00
jaseg
2451b517e8 Add aperture macro expression tests 2026-03-22 13:22:11 +01:00
jaseg
bdd4008ab9 Fix macro polygon offset handling and add regression test
Applies github PR #1. Thanks to @SaumyaShah08 on github!
2026-03-22 11:46:47 +01:00
jaseg
6f006e2782 Start work on proper aperture macro tests 2026-03-21 13:24:50 +01:00
jaseg
8df709f55f Bump version to v1.6.2 2026-03-09 17:22:02 +01:00
jaseg
985e263cfe Add missing test data 2026-03-09 17:11:34 +01:00
jaseg
5ccfd7a259 Fix handling of zero-length line segments during pretty SVG export
Fixess gitlab issue #8
2026-03-09 17:10:15 +01:00
jaseg
bcc4aeefa7 doc: State more explicitly the nature of gerbonara's file name matching rules 2026-03-09 16:34:09 +01:00
jaseg
0c15111463 Add pretty_svg test, fix some bugs. 2026-03-09 16:31:08 +01:00
jaseg
1a0f519720 Add invert polarity test
Fixes gitlab issue #6
2026-03-09 16:07:37 +01:00
jaseg
3d0ddc3dc8 Update layer naming to track modern kicad 2026-03-08 16:49:26 +01:00
jaseg
575046a60c Fix ExcellonFile.to_gerber and add a unit test 2026-03-08 15:12:58 +01:00
jaseg
8de776616c Fix aperture macro thermal primitive
Fixes gitlab issues #10 and #11
2026-03-08 15:03:11 +01:00
jaseg
e19dec20b6 Fix ArcPoly primitive approximate_arcs 2026-03-08 14:38:13 +01:00
jaseg
97513df5d9 Improve regression test for gitlab issue #17
Fixes gitlab issue #17
2026-03-08 14:29:56 +01:00
jaseg
2ce55ebdca Fix arcpoly conversion bugs 2026-03-08 14:19:41 +01:00
jaseg
f3c95a42d4 Fix bug in rs274x Region close
Fixes gitlab issue #18
2026-03-08 13:23:29 +01:00
jaseg
8a2599f5f4 Add missing degree/radian conversion for polygon aperture rotation 2026-03-08 13:17:16 +01:00
jaseg
2647709215 Fix arc gerber export bug 2026-03-08 11:24:46 +01:00
jaseg
87413855bf tests WIP 2026-02-22 14:50:03 +01:00
Nitsan-Suron
af4fb2668f fixed invert_polarity 2026-02-22 12:42:22 +01:00
Matthew Mets
a47a694ba0 Convert polygonal aperture rotations to radians
Polygonal aperture rotations are specified in degrees, however
gerbonara wants radians. This is a fix for issue #12 :

https://gitlab.com/gerbolyze/gerbonara/-/issues/12
2025-12-10 13:32:10 +00:00
jaseg
dd8ad98f13 Bump version to v1.6.1 2025-12-10 14:23:14 +01:00
jaseg
a1ea416269 Fix uv build for newer container versions 2025-12-10 14:23:14 +01:00
jaseg
a7f0324506 Add protoserve entrypoint 2025-12-10 14:13:00 +01:00
jaseg
6ab4640099 Bump version to v1.6.0 2025-12-08 12:13:14 +01:00
jaseg
04a088c3e8 Update gitlab CI for uv build 2025-12-08 12:13:11 +01:00
jaseg
df4895abfa kicad: make footprint render layer_map argument optional 2025-12-08 11:52:42 +01:00
jaseg
42dfd1be7f Bring kicad PCB file format up to 9.0.5 2025-11-19 12:26:03 +01:00
jaseg
fd6880640d Add missing test file 2025-11-19 11:00:38 +01:00
jaseg
6a8bd8dc3f Kicad 9.0.5 PCB compatibility WIP 2025-11-18 23:43:29 +01:00
jaseg
46df184358 Fix remaining failing tests
This brings schematic i/o compatiblity up to 9.0.5.
2025-11-18 23:11:22 +01:00
jaseg
c2679260a0 Make test logic resource updating smarter
Only update repos/podman images if they're not as expected. Also only
run footprint export on changes.
2025-11-18 22:13:45 +01:00
jaseg
9d2d635eee kiacd 9.0 compatibility WIP 2025-11-17 17:38:59 +01:00
jaseg
75905f7d0c Fix failing tests
This adds basic support for kicad 9.0 library i/o
2025-11-17 12:07:15 +01:00
jaseg
7073b6e33f Finish transition to uv 2025-11-16 21:19:13 +01:00
jaseg
718de2b338 WIP move project to uv 2025-11-16 19:37:48 +01:00
jaseg
379cf273cb Fix out-of-date documentation 2025-08-22 13:24:34 +02:00
jaseg
da6d8349fa Bump version to v1.5.0 2025-07-09 18:46:10 +02:00
jaseg
1f5a9261e1 cli: Fix transform cli 2025-07-09 18:45:16 +02:00
jaseg
3ae6b9be61 kicad: fix for newer format versions 2025-04-02 17:49:13 +02:00
jaseg
a6434f16cd Fix clobbered object attributes 2025-04-01 15:52:53 +02:00
jaseg
63faf4280d Fix failing tests 2024-12-21 18:54:00 +01:00
jaseg
7fb8215a1e Add recent fusion-360 files as test resource 2024-12-21 17:32:16 +01:00
jaseg
81ae51d4be Improve allegro inner layer matching 2024-11-08 12:26:02 +01:00
jaseg
8ffa7c1b76 Fix svg orientation and improve OrCAD rendering 2024-11-08 11:41:11 +01:00
jaseg
be8371c7bc Improve allegro/orcad import 2024-11-06 14:49:50 +01:00
jaseg
1a854b1812 Tests: Make kicad docker image configurable 2024-11-06 14:49:36 +01:00
jaseg
091ee84910 kicad: add rotation method to circles and polygons 2024-07-19 19:28:15 +02:00
jaseg
fd63c44314 Kicad: add missing zone connection Atom 2024-07-19 19:23:20 +02:00
jaseg
cbe8dfa252 kicad: small syntax fix 2024-07-19 19:23:11 +02:00
jaseg
67ce4af957 kicad: Fix bug in footprint search 2024-07-19 19:22:56 +02:00
jaseg
eadd250ee3 kicad: Fix footprint rotation
Previously, footprint rotation was mirrored compared with everything
else in the kicad API
2024-07-19 19:22:26 +02:00
jaseg
5f5bbccd05 kicad: Add copy_placement method to footprints
Moving footprints around is ugly because of kicad's really weird way of
specifying sub-object coordinates and rotations. This commit adds a
helper method to deal with that.
2024-07-19 19:21:24 +02:00
jaseg
ea2664219d kicad: Make reprs more reasonable
This excludes parent back-links from reprs, which would previously blow
up the repr of many objects.
2024-07-19 19:20:51 +02:00
jaseg
be25b860a9 kicad: Improve net access
This adds net_name and net_index properties to a bunch of objects that
automatically look into the (net s-expr of the object
2024-07-19 19:20:09 +02:00
jaseg
e42b7462c9 kicad: Add nicer width access
This commit straightens up the mess a bit with kicad's two conflicting
"width" fields in different file format versions. The new variant is
preferred when saving, but the old variant is accepted as API
2024-07-19 19:18:55 +02:00
jaseg
754c9557e5 kicad: Make point lists more ergonomic
This commit removes the unnecessary "PointList" wrapper class, and just
puts regular python lists in its place.
2024-07-19 19:17:46 +02:00
jaseg
d7efa57732 kicad: Add bounding box support to lots of s-expr objects 2024-07-19 19:15:52 +02:00
jaseg
689ce748db kicad: Update empty PCB template for 8.99 2024-07-19 14:09:40 +02:00
jaseg
8e337c0506 kicad: Small fixes to 8.99 compatibility 2024-07-19 14:07:29 +02:00
jaseg
b23cafbfd4 tests: add status messages 2024-07-19 14:04:24 +02:00
jaseg
78ffb61aee kicad: Fixes for latest git version 2024-07-19 12:25:13 +02:00
jaseg
58d5784903 rs274x: Add support for SR step-repeat command 2024-07-18 16:39:58 +02:00
jaseg
1ecb7be6f9 Improve aperture macros doc 2024-07-18 16:39:38 +02:00
jaseg
ef9d61ffd5 Fix single contour example syntax errors 2024-07-18 14:19:31 +02:00
jaseg
79f555a465 macro parser: improve parameter number warning message 2024-07-18 14:19:17 +02:00
jaseg
501882ea3d Fix ast deprecation warnings 2024-07-18 14:19:04 +02:00
jaseg
e3a6716187 Fix symbol round trip tests 2024-07-18 13:50:01 +02:00
jaseg
8950a593f8 Fix bug in SVG bounding box calculation 2024-07-18 13:49:38 +02:00
jaseg
344825c5da kicad: Fix bug when searching pads with empty nets 2024-07-18 11:36:16 +02:00
jaseg
f1b3ab5e72 kicad: Improve compatibility with old symbol files 2024-07-18 11:36:13 +02:00
jaseg
2092b86431 tests: Always update kicad nightly container before run 2024-07-18 11:35:03 +02:00
jaseg
885ce36fd3 tests: Adjust nice and oom_adj values on linux
The testsuite calls resvg, and that sometimes can use up *a lot* of
memory. We adjust the test process tree's nice values and oom_adj values
to make sure the test processes get killed during an OOM condition
before anything else on the system.
2024-07-18 11:33:46 +02:00
jaseg
edaf246b9d Update KiCad PCB format support to 8.99 2024-07-17 10:20:35 +02:00
jaseg
b1d0260c70 Bump version to v1.4.0 2024-07-08 18:11:18 +02:00
jaseg
a2c6e2d64a Add missing package data 2024-07-08 18:10:57 +02:00
jaseg
1d5f4c8193 protoboard: Fix bug with empty points in breadboard rendering 2024-07-08 18:10:57 +02:00
jaseg
30878adfb1 Bump version to v1.3.0 2024-07-08 17:47:02 +02:00
jaseg
66811af966 kicad: Bring file format up to current 8.99 nightly 2024-07-08 16:29:57 +02:00
jaseg
ec85d6c169 tests: Speed up tests by a lot by bulk-caching kicad footprint renders 2024-07-08 16:29:35 +02:00
jaseg
f447b12571 tests: Fix compatibility with recent kicad nightly containers 2024-07-08 16:29:10 +02:00
jaseg
93fd764482 protoboard: Add CLI 2024-07-07 22:48:19 +02:00
jaseg
26e85279dc protoboard: improve permanent breadboard rail rendering 2024-07-07 21:57:29 +02:00
jaseg
1ed127e3b3 protoboard: improve permanent breadboard rendering 2024-07-07 21:55:17 +02:00
jaseg
1ee6b6587a protoboard: Add permanent breadboard rendering 2024-07-07 21:42:10 +02:00
jaseg
e98f3f3ace Add starburst pattern 2024-07-07 19:07:58 +02:00
jaseg
6f8d4bb999 protoboard: Finish up sides of Alio better 2024-07-07 17:08:06 +02:00
jaseg
4aab344a18 protoboard: add split front/back view in webthing 2024-07-07 16:52:24 +02:00
jaseg
21218239e4 protoboard: Fix alio and two-sided SMD rendering 2024-07-07 16:37:34 +02:00
jaseg
a1d6ebf79f protoboard: Improve layout distribution and index rendering 2024-07-07 16:25:01 +02:00
jaseg
b0274a93c0 protoboard: Improve layout packing 2024-07-07 16:08:32 +02:00
jaseg
0150c318bb protoboard: Improve row/column numbering 2024-07-07 15:48:36 +02:00
jaseg
6de138bf7c protoboard: reduce hole keepout margins 2024-07-07 15:16:01 +02:00
jaseg
224a666219 protoboard: improve border handling 2024-07-07 15:14:29 +02:00
jaseg
cee355ff57 protoboard: fix column label alignment 2024-07-07 14:40:27 +02:00
jaseg
4c3815b25a Fix THT flower proto area 2024-07-07 14:36:41 +02:00
jaseg
ef3b5d5e1c Spiky proto also works now 2024-07-07 00:52:38 +02:00
jaseg
552f30c15d Protoboard: All layouts except for spiky proto work 2024-07-07 00:32:17 +02:00
jaseg
f721692bf3 Protoboard generator WIP 2024-07-06 15:51:08 +02:00
jaseg
04c4b3ff0c kicad_sch render: Fix nightly import and wire rendering 2024-05-28 18:07:09 +02:00
575 changed files with 140577 additions and 2311 deletions

2
.gitignore vendored
View file

@ -3,3 +3,5 @@ gerbonara_test_failures
__pycache__
.tox
docs/_build/
build
dist

View file

@ -14,8 +14,7 @@ build:archlinux:
GIT_SUBMODULE_STRATEGY: none
script:
- git config --global --add safe.directory "$CI_PROJECT_DIR"
- pip3 install --user --break-system-packages wheel setuptools
- python3 setup.py sdist bdist_wheel
- uv build
artifacts:
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
paths:

View file

@ -60,6 +60,7 @@ layers, or whole board stacks (:py:class:`~.layers.LayerStack`) to SVG.
``gerbonara render`` renders one or more Gerber or Excellon files as a single SVG file. It can read single files,
directorys of files, and ZIP files. To read directories or zips, it applies gerbonara's layer filename matching rules.
These built-in rules should work with common settings in a wide variety of CAD tools.
.. option:: --warnings [default|ignore|once]

View file

@ -71,7 +71,7 @@ Then, you are ready to read and write gerber files:
from gerbonara import LayerStack
stack = LayerStack.from_directory('output/gerber')
stack = LayerStack.open('output/gerber')
w, h = stack.outline.size('mm')
print(f'Board size is {w:.1f} mm x {h:.1f} mm')

View file

@ -10,7 +10,7 @@ from gerbonara.utils import MM
from gerbonara.utils import rotate_point
def highlight_outline(input_dir, output_dir):
stack = LayerStack.from_directory(input_dir)
stack = LayerStack.open(input_dir)
outline = []
for obj in stack.outline.objects:
@ -28,7 +28,6 @@ def highlight_outline(input_dir, output_dir):
marker_nx, marker_ny = math.sin(marker_angle), math.cos(marker_angle)
ap = CircleAperture(0.1, unit=MM)
stack['top silk'].apertures.append(ap)
for line in outline:
cx, cy = (line.x1 + line.x2)/2, (line.y1 + line.y2)/2

View file

@ -7,5 +7,5 @@ if __name__ == '__main__':
args = parser.parse_args()
import gerbonara
print(gerbonara.LayerStack.from_directory(args.input))
print(gerbonara.LayerStack.open(args.input))

View file

@ -2,6 +2,7 @@
import math
from gerbonara.utils import MM
from gerbonara.graphic_objects import Arc
from gerbonara.graphic_objects import rotate_point
@ -22,7 +23,8 @@ def approx_test():
x1, y1 = rotate_point(0, -1, start_angle*eps)
x2, y2 = rotate_point(x1, y1, sweep_angle*eps*(-1 if clockwise else 1))
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None, polarity_dark=True)
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None,
polarity_dark=True, unit=MM)
lines = arc.approximate(max_error=max_error)
print(f'<path style="fill: {color}; stroke: none;" d="M {cx} {cy} L {lines[0].x1} {lines[0].y1}', end=' ')

View file

@ -1,257 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
from dataclasses import dataclass, field, replace
import operator
import re
import ast
import copy
import warnings
import math
from . import primitive as ap
from .expression import *
from ..utils import MM
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
def rad_to_deg(x):
return (x / math.pi) * 180
def _map_expression(node, variables={}, parameters=set()):
if isinstance(node, ast.Num):
return ConstantExpression(node.n)
elif isinstance(node, ast.BinOp):
op_map = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
return OperatorExpression(op_map[type(node.op)],
_map_expression(node.left, variables, parameters),
_map_expression(node.right, variables, parameters))
elif isinstance(node, ast.UnaryOp):
if type(node.op) == ast.UAdd:
return _map_expression(node.operand, variables, parameters)
else:
return NegatedExpression(_map_expression(node.operand, variables, parameters))
elif isinstance(node, ast.Name):
num = int(node.id[3:]) # node.id has format var[0-9]+
if num in variables:
return VariableExpression(variables[num])
else:
parameters.add(num)
return ParameterExpression(num)
else:
raise SyntaxError('Invalid aperture macro expression')
def _parse_expression(expr, variables, parameters):
expr = expr.lower().replace('x', '*')
expr = re.sub(r'\$([0-9]+)', r'var\1', expr)
try:
parsed = ast.parse(expr, mode='eval').body
except SyntaxError as e:
raise SyntaxError('Invalid aperture macro expression') from e
return _map_expression(parsed, variables, parameters)
@dataclass(frozen=True, slots=True)
class ApertureMacro:
name: str = field(default=None, hash=False, compare=False)
num_parameters: int = 0
primitives: tuple = ()
comments: tuple = field(default=(), hash=False, compare=False)
def __post_init__(self):
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name):
# We can't use field(default_factory=...) here because that factory doesn't get a reference to the instance.
self._reset_name()
def _reset_name(self):
object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
@classmethod
def parse_macro(kls, macro_name, body, unit):
comments = []
variables = {}
parameters = set()
primitives = []
blocks = body.split('*')
for block in blocks:
if not (block := block.strip()): # empty block
continue
if block.startswith('0 '): # comment
comments.append(block[2:])
continue
block = re.sub(r'\s', '', block)
if block[0] == '$': # variable definition
try:
name, _, expr = block.partition('=')
number = int(name[1:])
if number in variables:
warnings.warn(f'Re-definition of aperture macro variable ${number} inside macro. Previous definition of ${number} was ${variables[number]}.')
variables[number] = _parse_expression(expr, variables, parameters)
except Exception as e:
raise SyntaxError(f'Error parsing variable definition {block!r}') from e
else: # primitive
primitive, *args = block.split(',')
args = [ _parse_expression(arg, variables, parameters) for arg in args ]
try:
primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args))
except KeyError as e:
raise SyntaxError(f'Unknown aperture macro primitive code {int(primitive)}')
return kls(macro_name, max(parameters, default=0), tuple(primitives), tuple(comments))
def __str__(self):
return f'<Aperture macro {self.name}, primitives {self.primitives}>'
def __repr__(self):
return str(self)
def dilated(self, offset, unit=MM):
new_primitives = []
for primitive in self.primitives:
try:
if primitive.exposure.calculate():
new_primitives += primitive.dilated(offset, unit)
except IndexError:
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
pass
return replace(self, primitives=tuple(new_primitives))
def substitute_params(self, params, unit=None, macro_name=None):
params = dict(enumerate(params, start=1))
return replace(self,
num_parameters=0,
name=macro_name,
primitives=tuple(p.substitute_params(params, unit) for p in self.primitives),
comments=(f'Fully substituted instance of {self.name} macro',
f'Original parameters: {"X".join(map(str, params.values())) if params else "none"}'))
def to_gerber(self, settings):
""" Serialize this macro's content (without the name) into Gerber using the given file unit """
comments = [ f'0 {c.replace("*", "_").replace("%", "_")}' for c in self.comments ]
subexpression_variables = {}
def register_variable(expr):
expr_str = expr.to_gerber(register_variable, settings.unit)
if expr_str not in subexpression_variables:
subexpression_variables[expr_str] = self.num_parameters + 1 + len(subexpression_variables)
return subexpression_variables[expr_str]
primitive_defs = [prim.to_gerber(register_variable, settings) for prim in self.primitives]
variable_defs = [f'${num}={expr_str}' for expr_str, num in subexpression_variables.items()]
return '*\n'.join(comments + variable_defs + primitive_defs)
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
parameters = dict(enumerate(parameters, start=1))
for primitive in self.primitives:
yield from primitive.to_graphic_primitives(offset, rotation, parameters, unit, polarity_dark)
def rotated(self, angle):
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
return replace(self, primitives=tuple(
replace(primitive, rotation=primitive.rotation - rad_to_deg(angle)) for primitive in self.primitives))
def scaled(self, scale):
return replace(self, primitives=tuple(
primitive.scaled(scale) for primitive in self.primitives))
var = ParameterExpression
deg_per_rad = 180 / math.pi
class GenericMacros:
_generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),)
# NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing
# API.
circle = ApertureMacro('GNC', 4, (
ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad),
*_generic_hole(2)))
rect = ApertureMacro('GNR', 5, (
ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad),
*_generic_hole(3)))
# params: width, height, corner radius, *hole, rotation
rounded_rect = ApertureMacro('GRR', 6, (
ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad),
ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
*_generic_hole(4)))
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation
isosceles_trapezoid = ApertureMacro('GTR', 6, (
ap.Outline('mm', 1, 4,
(var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
var(6) * -deg_per_rad),
*_generic_hole(4)))
# params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation
rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, (
ap.Outline('mm', 1, 4,
(var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
var(7) * -deg_per_rad),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/-2, var(2)/-2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/-2+var(3)/2, var(2)/2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/2-var(3)/2, var(2)/2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/2, var(2)/-2,),
*_generic_hole(5)))
# w must be larger than h
# params: width, height, *hole, rotation
obround = ApertureMacro('GNO', 5, (
ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad),
ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
*_generic_hole(3) ))
polygon = ApertureMacro('GNP', 4, (
ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad),
ap.Circle('mm', 0, var(4), 0, 0)))
if __name__ == '__main__':
import sys
#for line in sys.stdin:
#expr = _parse_expression(line.strip())
#print(expr, '->', expr.optimized())
for primitive in parse_macro(sys.stdin.read(), 'mm'):
print(primitive)

View file

@ -1,739 +0,0 @@
import sys
import re
import math
import string
import itertools
from copy import copy, deepcopy
import warnings
import importlib.resources
from ..utils import MM, rotate_point
from .primitives import *
from ..graphic_objects import Region
from ..apertures import RectangleAperture, CircleAperture, ApertureMacroInstance
from ..aperture_macros.parse import ApertureMacro, VariableExpression
from ..aperture_macros import primitive as amp
from .kicad import footprints as kfp
from . import data as package_data
class ProtoBoard(Board):
def __init__(self, w, h, content, margin=None, corner_radius=None, mounting_hole_dia=None, mounting_hole_offset=None, unit=MM):
corner_radius = corner_radius or unit(1.5, MM)
super().__init__(w, h, corner_radius, unit=unit)
self.margin = margin or unit(2, MM)
self.content = content
if mounting_hole_dia:
mounting_hole_offset = mounting_hole_offset or mounting_hole_dia*2
ko = mounting_hole_offset*2
self.add(Hole(mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(w-mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(w-mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
self.keepouts.append(((0, 0), (ko, ko)))
self.keepouts.append(((w-ko, 0), (w, ko)))
self.keepouts.append(((0, h-ko), (ko, h)))
self.keepouts.append(((w-ko, h-ko), (w, h)))
self.generate()
def generate(self, unit=MM):
bbox = ((self.margin, self.margin), (self.w-self.margin, self.h-self.margin))
bbox = unit.convert_bounds_from(self.unit, bbox)
for obj in self.content.generate(bbox, (True, True, True, True), unit):
self.add(obj, keepout_errors='skip')
class PropLayout:
def __init__(self, content, direction, proportions):
self.content = list(content)
if direction not in ('h', 'v'):
raise ValueError('direction must be one of "h", or "v".')
self.direction = direction
self.proportions = list(proportions)
if len(content) != len(proportions):
raise ValueError('proportions and content must have same length')
def generate(self, bbox, border_text, unit=MM):
for i, (bbox, child) in enumerate(self.layout_2d(bbox, unit)):
first = bool(i == 0)
last = bool(i == len(self.content)-1)
yield from child.generate(bbox, (
border_text[0] and (last or self.direction == 'h'),
border_text[1] and (last or self.direction == 'v'),
border_text[2] and (first or self.direction == 'h'),
border_text[3] and (first or self.direction == 'v'),
), unit)
def fit_size(self, w, h, unit=MM):
widths = []
heights = []
for ((x_min, y_min), (x_max, y_max)), child in self.layout_2d(((0, 0), (w, h)), unit):
if not isinstance(child, EmptyProtoArea):
widths.append(x_max - x_min)
heights.append(y_max - y_min)
if self.direction == 'h':
return sum(widths), max(heights, default=0)
else:
return max(widths, default=0), sum(heights)
def layout_2d(self, bbox, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
actual_l = 0
target_l = 0
for l, child in zip(self.layout(w if self.direction == 'h' else h, unit), self.content):
this_x, this_y = x, y
this_w, this_h = w, h
target_l += l
if self.direction == 'h':
this_w = target_l - actual_l
else:
this_h = target_l - actual_l
this_w, this_h = child.fit_size(this_w, this_h, unit)
if self.direction == 'h':
x += this_w
actual_l += this_w
this_h = h
else:
y += this_h
actual_l += this_h
this_w = w
yield ((this_x, this_y), (this_x+this_w, this_y+this_h)), child
def layout(self, length, unit=MM):
out = [ eval_value(value, MM(length, unit)) for value in self.proportions ]
total_length = sum(value for value in out if value is not None)
if length - total_length < -1e-6:
raise ValueError(f'Proportions sum to {total_length} mm, which is greater than the available space of {length} mm.')
leftover = length - total_length
sum_props = sum( (value or 1.0) for value in self.proportions if not isinstance(value, str) )
return [ unit(leftover * (value or 1.0) / sum_props if not isinstance(value, str) else calculated, MM)
for value, calculated in zip(self.proportions, out) ]
@property
def single_sided(self):
return all(elem.single_sided for elem in self.content)
def __str__(self):
children = ', '.join( f'{elem}:{width}' for elem, width in zip(self.content, self.proportions))
return f'PropLayout[{self.direction.upper()}]({children})'
class TwoSideLayout:
def __init__(self, top, bottom):
self.top, self.bottom = top, bottom
if not top.single_sided or not bottom.single_sided:
warnings.warn('Two-sided pattern used on one side of a TwoSideLayout')
def fit_size(self, w, h, unit=MM):
w1, h1 = self.top.fit_size(w, h, unit)
w2, h2 = self.bottom.fit_size(w, h, unit)
if isinstance(self.top, EmptyProtoArea):
if isinstance(self.bottom, EmptyProtoArea):
return w1, h1
return w2, h2
if isinstance(self.bottom, EmptyProtoArea):
return w1, h1
return max(w1, w2), max(h1, h2)
def generate(self, bbox, border_text, unit=MM):
yield from self.top.generate(bbox, border_text, unit)
for obj in self.bottom.generate(bbox, border_text, unit):
obj.side = 'bottom'
yield obj
def numeric(start=1):
def gen():
nonlocal start
for i in itertools.count(start):
yield str(i)
return gen
def alphabetic(case='upper'):
if case not in ('lower', 'upper'):
raise ValueError('case must be one of "lower" or "upper".')
index = string.ascii_lowercase if case == 'lower' else string.ascii_uppercase
def gen():
nonlocal index
for i in itertools.count():
if i<26:
yield index[i]
continue
i -= 26
if i<26*26:
yield index[i//26] + index[i%26]
continue
i -= 26*26
if i<26*26*26:
yield index[i//(26*26)] + index[(i//26)%26] + index[i%26]
else:
raise ValueError('row/column index out of range')
return gen
class PatternProtoArea:
def __init__(self, pitch_x, pitch_y=None, obj=None, numbers=True, font_size=None, font_stroke=None, number_x_gen=alphabetic(), number_y_gen=numeric(), interval_x=5, interval_y=None, margin=0, unit=MM):
self.pitch_x = pitch_x
self.pitch_y = pitch_y or pitch_x
self.margin = margin
self.obj = obj
self.unit = unit
self.numbers = numbers
self.font_size = font_size or unit(1.0, MM)
self.font_stroke = font_stroke or unit(0.2, MM)
self.interval_x = interval_x
self.interval_y = interval_y or (1 if MM(self.pitch_y, unit) >= 2.0 else 5)
self.number_x_gen, self.number_y_gen = number_x_gen, number_y_gen
def fit_size(self, w, h, unit=MM):
(min_x, min_y), (max_x, max_y) = self.fit_rect(((0, 0), (max(0, w-2*self.margin), max(0, h-2*self.margin))))
return max_x-min_x + 2*self.margin, max_y-min_y + 2*self.margin
def fit_rect(self, bbox, unit=MM):
(x, y), (w, h) = bbox
x, y = x+self.margin, y+self.margin
w, h = w-x-self.margin, h-y-self.margin
w_mod = round((w + 5e-7) % unit(self.pitch_x, self.unit), 6)
h_mod = round((h + 5e-7) % unit(self.pitch_y, self.unit), 6)
w_fit, h_fit = round(w - w_mod, 6), round(h - h_mod, 6)
x = x + (w-w_fit)/2
y = y + (h-h_fit)/2
return (x, y), (x+w_fit, y+h_fit)
def generate(self, bbox, border_text, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
n_x = int(w//unit(self.pitch_x, self.unit))
n_y = int(h//unit(self.pitch_y, self.unit))
off_x = (w % unit(self.pitch_x, self.unit)) / 2
off_y = (h % unit(self.pitch_y, self.unit)) / 2
if self.numbers:
for i, lno_i in list(zip(range(n_y), self.number_y_gen())):
if i == 0 or i == n_y - 1 or (i+1) % self.interval_y == 0:
t_y = off_y + y + (n_y - 1 - i + 0.5) * self.pitch_y
if border_text[3]:
t_x = x + off_x
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', side='bottom', unit=self.unit)
if border_text[1]:
t_x = x + w - off_x
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', side='bottom', unit=self.unit)
for i, lno_i in zip(range(n_x), self.number_x_gen()):
if i == 0 or i == n_x - 1 or (i+1) % self.interval_x == 0:
t_x = off_x + x + (i + 0.5) * self.pitch_x
if border_text[2]:
t_y = y + off_y
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', side='bottom', unit=self.unit)
if border_text[0]:
t_y = y + h - off_y
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', side='bottom', unit=self.unit)
for i in range(n_x):
for j in range(n_y):
if hasattr(self.obj, 'inst'):
inst = self.obj.inst(i, j, i == n_x-1, j == n_y-1)
if not inst:
continue
else:
inst = copy(self.obj)
inst.x = inst.unit(off_x + x, unit) + (i + 0.5) * inst.unit(self.pitch_x, self.unit)
inst.y = inst.unit(off_y + y, unit) + (j + 0.5) * inst.unit(self.pitch_y, self.unit)
yield inst
@property
def single_sided(self):
return self.obj.single_sided
class EmptyProtoArea:
def __init__(self, copper_fill=False):
self.copper_fill = copper_fill
def fit_size(self, w, h, unit=MM):
return w, h
def generate(self, bbox, border_text, unit=MM):
if self.copper_fill:
(min_x, min_y), (max_x, max_y) = bbox
group = ObjectGroup(0, 0, top_copper=[Region([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)],
unit=unit, polarity_dark=True)])
group.bounding_box = lambda *args, **kwargs: None
yield group
@property
def single_sided(self):
return True
class ManhattanPads(ObjectGroup):
def __init__(self, w, h=None, gap=0.2, unit=MM):
super().__init__(0, 0)
h = h or w
self.gap = gap
self.unit = unit
p = (w-2*gap)/2
q = (h-2*gap)/2
small_ap = RectangleAperture(p, q, unit=unit)
s = min(w, h) / 2 / math.sqrt(2)
large_ap = RectangleAperture(s, s, unit=unit).rotated(math.pi/4)
large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, unit=unit).rotated(math.pi/4)
a = gap/2 + p/2
b = gap/2 + q/2
self.top_copper.append(Flash(-a, -b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash(-a, b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash( a, -b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash( a, b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=large_ap_neg, polarity_dark=False, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=large_ap, unit=unit))
self.top_mask = self.top_copper
class RFGroundProto(ObjectGroup):
def __init__(self, pitch=None, drill=None, clearance=None, via_dia=None, via_drill=None, pad_dia=None, trace_width=None, unit=MM):
super().__init__(0, 0)
self.unit = unit
self.pitch = pitch = pitch or unit(2.54, MM)
self.drill = drill = drill or unit(0.9, MM)
self.clearance = clearance = clearance or unit(0.3, MM)
self.via_drill = via_drill = via_drill or unit(0.4, MM)
self.via_dia = via_dia = via_dia or unit(0.8, MM)
if pad_dia is None:
self.trace_width = trace_width = trace_width or unit(0.3, MM)
pad_dia = pitch - trace_width - 2*clearance
elif trace_width is None:
trace_width = pitch - pad_dia - 2*clearance
self.pad_dia = pad_dia
via_ap = RectangleAperture(via_dia, via_dia, unit=unit).rotated(math.pi/4)
pad_ap = CircleAperture(pad_dia, unit=unit)
pad_neg_ap = CircleAperture(pad_dia+2*clearance, unit=unit)
ground_ap = RectangleAperture(pitch + unit(0.01, MM), pitch + unit(0.01, MM), unit=unit)
pad_drill = ExcellonTool(drill, plated=True, unit=unit)
via_drill = ExcellonTool(via_drill, plated=True, unit=unit)
self.top_copper.append(Flash(0, 0, aperture=ground_ap, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=pad_neg_ap, polarity_dark=False, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=pad_ap, unit=unit))
self.top_mask.append(Flash(0, 0, aperture=pad_ap, unit=unit))
self.top_copper.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
self.top_mask.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
self.drill_pth.append(Flash(0, 0, aperture=pad_drill, unit=unit))
self.drill_pth.append(Flash(pitch/2, pitch/2, aperture=via_drill, unit=unit))
self.bottom_copper = self.top_copper
self.bottom_mask = self.top_mask
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if border_x or border_y:
inst.drill_pth = inst.drill_pth[:-1]
inst.top_copper = inst.bottom_copper = inst.top_copper[:-1]
inst.top_mask = inst.bottom_mask = inst.top_mask[:-1]
return inst
class THTFlowerProto(ObjectGroup):
def __init__(self, pitch=None, drill=None, diameter=None, unit=MM):
super().__init__(0, 0, unit=unit)
self.pitch = pitch = pitch or unit(2.54, MM)
drill = drill or unit(0.9, MM)
diameter = diameter or unit(2.0, MM)
p = pitch / 2
self.objects.append(THTPad.circle(-p, 0, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle( p, 0, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle(0, -p, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle(0, p, drill, diameter, paste=False, unit=unit))
middle_ap = CircleAperture(diameter, unit=unit)
self.top_copper.append(Flash(0, 0, aperture=middle_ap, unit=unit))
self.bottom_copper = self.top_mask = self.bottom_mask = self.top_copper
def inst(self, x, y, border_x, border_y):
if (x % 2 == 0) and (y % 2 == 0):
return copy(self)
if (x % 2 == 1) and (y % 2 == 1):
return copy(self)
return None
def bounding_box(self, unit=MM):
x, y, rotation = self.abs_pos
p = self.pitch/2
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
class PoweredProto(ObjectGroup):
""" Cell primitive for "powered" THT breadboards. This cell type is based on regular THT pads in a 100 mil grid, but
adds small SMD pads diagonally between the THT pads. These SMD pads are interconnected with traces and vias in such
a way that every second one is inter-linked, forming two fully connected grids. Next to every THT pad you have one
pad of each grid, so this layout is awesome for distributing power throughout the board.
This design is based on one that Yajima Manufacturing Akizuki Denshi, Akihabara's finest electronics store sells for
next to nothing. Sadly, they don't ship internationally and they don't even have an English website, but if you ever
are in Akihabara, Tokyo, Japan I can *highly* recommend a visit. The ones Yajima make are better than what this will
produce since the Yajima ones use a two-colored silkscreen to visually distinguish the two power pad grids.
Links:
Akizuki Denshi product page: https://akizukidenshi.com/catalog/g/gP-07214/
Yajima Manufacturing Corporation website: http://www.yajima-works.co.jp/index.html
"""
def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM):
super().__init__(0, 0)
self.unit = unit
self.pitch = pitch = pitch or unit(2.54, MM)
self.drill = drill = drill or unit(0.9, MM)
self.clearance = clearance = clearance or unit(0.3, MM)
self.trace_width = trace_width = trace_width or unit(0.3, MM)
self.via_size = via_size = via_size or unit(0.4, MM)
main_pad_dia = pitch - trace_width - 2*clearance
power_pad_dia_max = math.sqrt(2)*pitch - main_pad_dia - 2*clearance
if power_pad_dia is None:
power_pad_dia = power_pad_dia_max - clearance # reduce some more to give the user more room
elif power_pad_dia > power_pad_dia_max:
warnings.warn(f'Power pad diameter {power_pad_dia} > {power_pad_dia_max} violates pad-to-pad clearance')
self.power_pad_dia = power_pad_dia
main_ap = CircleAperture(main_pad_dia, unit=unit)
power_ap = CircleAperture(self.power_pad_dia, unit=unit)
for l in [self.top_copper, self.bottom_copper]:
l.append(Flash(0, 0, aperture=main_ap, unit=unit))
l.append(Flash(-pitch/2, -pitch/2, aperture=power_ap, unit=unit))
l.append(Flash(-pitch/2, pitch/2, aperture=power_ap, unit=unit))
l.append(Flash( pitch/2, -pitch/2, aperture=power_ap, unit=unit))
l.append(Flash( pitch/2, pitch/2, aperture=power_ap, unit=unit))
self.drill_pth.append(Flash(0, 0, ExcellonTool(drill, plated=True, unit=unit), unit=unit))
self.drill_pth.append(Flash(-pitch/2, -pitch/2, ExcellonTool(via_size, plated=True, unit=unit), unit=unit))
self.top_mask = copy(self.top_copper)
self.bottom_mask = copy(self.bottom_copper)
self.line_ap = CircleAperture(trace_width, unit=unit)
self.top_copper.append(Line(-pitch/2, -pitch/2, -pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
self.top_copper.append(Line(pitch/2, -pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
self.bottom_copper.append(Line(-pitch/2, -pitch/2, pitch/2, -pitch/2, aperture=self.line_ap, unit=unit))
self.bottom_copper.append(Line(-pitch/2, pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if (x + y) % 2 == 0:
inst.drill_pth = inst.drill_pth[:-1]
c = self.power_pad_dia/2 + self.clearance
p = self.pitch/2
if x == 1:
inst.top_silk = [Line(-p, -p+c, -p, p-c, aperture=self.line_ap, unit=self.unit)]
elif x % 2 == 0:
inst.top_silk = [Line(p, -p+c, p, p-c, aperture=self.line_ap, unit=self.unit)]
if y == 0:
inst.bottom_silk = [Line(-p+c, -p, p-c, -p, aperture=self.line_ap, unit=self.unit)]
elif y % 2 == 1:
inst.bottom_silk = [Line(-p+c, p, p-c, p, aperture=self.line_ap, unit=self.unit)]
return inst
def bounding_box(self, unit=MM):
x, y, rotation = self.abs_pos
p = self.pitch/2
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
class SpikyProto(ObjectGroup):
""" Cell primitive for the "spiky" protoboard designed by @electroniceel and published on github at the URL below.
This layout has small-ish standard THT pads, but in between these pads it puts a grid of SMD pads that are designed
for easy solder bridging to allow for the construction of traces from solder bridging.
Github URL: https://github.com/electroniceel/protoboard
"""
def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM):
super().__init__(0, 0, unit=unit)
res = importlib.resources.files(package_data)
self.fp_center = kfp.Footprint.load(res.joinpath('center-pad-spikes.kicad_mod').read_text(encoding='utf-8'))
self.corner_pad = kfp.FootprintInstance(1.27, 1.27, self.fp_center, unit=MM)
self.pad = kfp.Footprint.load(res.joinpath('tht-0.8.kicad_mod').read_text(encoding='utf-8'))
self.center_pad = kfp.FootprintInstance(0, 0, self.pad, unit=MM)
self.fp_between = kfp.Footprint.load(res.joinpath('pad-between-spiked.kicad_mod').read_text(encoding='utf-8'))
self.right_pad = kfp.FootprintInstance(1.27, 0, self.fp_between, unit=MM)
self.top_pad = kfp.FootprintInstance(0, 1.27, self.fp_between, rotation=math.pi/2, unit=MM)
@property
def objects(self):
return [x for x in (self.center_pad, self.corner_pad, self.right_pad, self.top_pad) if x is not None]
@objects.setter
def objects(self, value):
pass
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if border_x:
inst.corner_pad = inst.right_pad = None
if border_y:
inst.corner_pad = inst.top_pad = None
return inst
class AlioCell(ObjectGroup):
""" Cell primitive for the ALio protoboard designed by arief ibrahim adha and published on hackaday.io at the URL
below. Similar to electroniceel's spiky protoboard, this layout has small-ish standard THT pads, but in between
these pads it puts a grid of SMD pads that are designed for easy solder bridging to allow for the construction of
traces from solder bridging.
Hackaday.io URL: https://hackaday.io/project/28570/
"""
def __init__(self, pitch=None, drill=None, clearance=None, link_pad_width=None, link_trace_width=None, via_size=None, unit=MM):
super().__init__(0, 0, unit=unit)
self.pitch = pitch or unit(2.54, MM)
self.drill = drill or unit(0.9, MM)
self.clearance = clearance or unit(0.3, MM)
self.link_pad_width = link_pad_width or unit(1.1, MM)
self.link_trace_width = link_trace_width or unit(0.5, MM)
self.via_size = via_size or unit(0.4, MM)
self.border_x, self.border_y = False, False
self.inst_x, self.inst_y = None, None
@property
def single_sided(self):
return False
def inst(self, x, y, border_x, border_y):
inst = copy(self)
inst.border_x, inst.border_y = border_x, border_y
inst.inst_x, inst.inst_y = x, y
return inst
def bounding_box(self, unit):
x, y, rotation = self.abs_pos
# FIXME hack
return self.unit.convert_bounds_to(unit, ((x-self.pitch/2, y-self.pitch/2), (x+self.pitch/2, y+self.pitch/2)))
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
def xf(fe):
fe = copy(fe)
fe.rotate(rotation)
fe.offset(x, y, self.unit)
return fe
var = VariableExpression
# parameters: [1: total height = pad width, 2: pitch, 3: trace width, 4: corner radius, 5: rotation, 6: clearance]
alio_main_macro = ApertureMacro('ALIOM', (
amp.CenterLine(MM, 1, var(2)-var(6), var(2)-var(3)-2*var(6), 0, 0, var(5)),
amp.Outline(MM, 0, 5, (
-var(2)/2, -var(2)/2,
-var(2)/2, -(var(7)-var(8)),
-var(7), -(var(7)-var(8)),
-(var(7)-var(8)), -var(7),
-(var(7)-var(8)), -var(2)/2,
-var(2)/2, -var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
-var(2)/2, var(2)/2,
-var(2)/2, (var(7)-var(8)),
-var(7), (var(7)-var(8)),
-(var(7)-var(8)), var(7),
-(var(7)-var(8)), var(2)/2,
-var(2)/2, var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
var(2)/2, -var(2)/2,
var(2)/2, -(var(7)-var(8)),
var(7), -(var(7)-var(8)),
(var(7)-var(8)), -var(7),
(var(7)-var(8)), -var(2)/2,
var(2)/2, -var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
var(2)/2, var(2)/2,
var(2)/2, (var(7)-var(8)),
var(7), (var(7)-var(8)),
(var(7)-var(8)), var(7),
(var(7)-var(8)), var(2)/2,
var(2)/2, var(2)/2,
), var(5)),
amp.Circle(MM, 0, 2*var(8), -var(7), -var(7), var(5)),
amp.Circle(MM, 0, 2*var(8), -var(7), var(7), var(5)),
amp.Circle(MM, 0, 2*var(8), var(7), -var(7), var(5)),
amp.Circle(MM, 0, 2*var(8), var(7), var(7), var(5)),
), (
None, # 1
None, # 2
None, # 3
None, # 4
None, # 5
None, # 6
var(2)/2 - var(1)/2 + var(4), # 7
var(4)+var(6), # 8
))
corner_radius = (self.link_pad_width - self.link_trace_width)/3
main_ap = ApertureMacroInstance(alio_main_macro, (self.link_pad_width, # 1
self.pitch, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation, # 5
self.clearance), unit=MM) # 6
main_ap_90 = ApertureMacroInstance(alio_main_macro, (self.link_pad_width, # 1
self.pitch, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation-90, # 5
self.clearance), unit=MM) # 6
main_drill = ExcellonTool(self.drill, plated=True, unit=self.unit)
via_drill = ExcellonTool(self.via_size, plated=True, unit=self.unit)
# parameters: [1: total height = pad width, 2: total width, 3: trace width, 4: corner radius, 5: rotation]
alio_macro = ApertureMacro('ALIOP', (
amp.CenterLine(MM, 1, var(1)-2*var(4), var(1), 0, 0, var(5)),
amp.CenterLine(MM, 1, var(1), var(1)-2*var(4), 0, 0, var(5)),
amp.Circle(MM, 1, 2*var(4), -var(1)/2+var(4), -var(1)/2+var(4), var(5)),
amp.Circle(MM, 1, 2*var(4), -var(1)/2+var(4), var(1)/2-var(4), var(5)),
amp.Circle(MM, 1, 2*var(4), var(1)/2-var(4), -var(1)/2+var(4), var(5)),
amp.Circle(MM, 1, 2*var(4), var(1)/2-var(4), var(1)/2-var(4), var(5)),
amp.CenterLine(MM, 1, var(2), var(3), -var(2)/2 + var(1)/2, 0, var(5)),
))
alio_dark = ApertureMacroInstance(alio_macro, (self.link_pad_width, # 1
self.pitch-self.clearance, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation), unit=MM) # 5
alio_dark_90 = ApertureMacroInstance(alio_macro, (self.link_pad_width, # 1
self.pitch-self.clearance, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation+90), unit=MM) # 5
# all layers are identical here
for side, use in (('top', 'copper'), ('top', 'mask'), ('bottom', 'copper'), ('bottom', 'mask')):
if side == 'top':
layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap, unit=self.unit)))
if not self.border_y:
layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark, unit=self.unit)))
else:
layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap_90, unit=self.unit)))
if not self.border_x:
layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark_90, unit=self.unit)))
layer_stack.drill_pth.append(Flash(x, y, aperture=main_drill, unit=self.unit))
if not (self.border_x or self.border_y):
layer_stack.drill_pth.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=via_drill, unit=self.unit)))
def convert_to_mm(value, unit):
unitl = unit.lower()
if unitl == 'mm':
return value
elif unitl == 'cm':
return value*10
elif unitl == 'in':
return value*25.4
elif unitl == 'mil':
return value/1000*25.4
else:
raise ValueError(f'Invalid unit {unit}, allowed units are mm, cm, in, and mil.')
_VALUE_RE = re.compile('([0-9]*\.?[0-9]+)(cm|mm|in|mil|%)')
def eval_value(value, total_length=None):
if not isinstance(value, str):
return None
m = _VALUE_RE.match(value.lower())
number, unit = m.groups()
if unit == '%':
if total_length is None:
raise ValueError('Percentages are not allowed for this value')
return total_length * float(number) / 100
return convert_to_mm(float(number), unit)
def _demo():
#pattern1 = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
#pattern1 = PatternProtoArea(2.54, 2.54, obj=SpikyProto())
#pattern2 = PatternProtoArea(1.2, 2.0, obj=SMDPad.rect(0, 0, 1.0, 1.8, paste=False))
#pattern3 = PatternProtoArea(2.54, 1.27, obj=SMDPad.rect(0, 0, 2.3, 1.0, paste=False))
#pattern3 = EmptyProtoArea(copper_fill=True)
#stack = TwoSideLayout(pattern2, pattern3)
#pattern2 = PatternProtoArea(2.54, obj=PoweredProto(), margin=1)
#pattern3 = PatternProtoArea(2.54, obj=RFGroundProto())
#stack = PropLayout([pattern2, pattern3], 'h', [0.5, 0.5])
#pattern = PropLayout([pattern1, stack], 'h', [0.5, 0.5])
#pattern = PatternProtoArea(2.54, obj=ManhattanPads(2.54))
#pattern = PatternProtoArea(2.54*1.5, obj=THTFlowerProto())
#pattern = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
#pattern = PatternProtoArea(2.54, obj=PoweredProto())
pattern = PatternProtoArea(2.54, obj=AlioCell(), margin=2)
pb = ProtoBoard(50, 47, pattern, mounting_hole_dia=3.2, mounting_hole_offset=5)
#pb = ProtoBoard(10, 10, pattern1)
print(pb.pretty_svg())
pb.layer_stack().save_to_directory('/tmp/testdir')
if __name__ == '__main__':
_demo()
#cnt = alphabetic()()
#for _ in range(32):
# for _ in range(26):
# print(f'{next(cnt):>2}', end=' ', file=sys.stderr)
# print(file=sys.stderr)

View file

@ -1,64 +0,0 @@
import os
from pathlib import Path
from itertools import chain
import pytest
from .image_support import ImageDifference, run_cargo_cmd
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
diff = left if isinstance(left, ImageDifference) else right
return [
f'Image difference assertion failed.',
f' Calculated difference: {diff}',
f' Histogram: {diff.histogram}', ]
# store report in node object so tmp_gbr can determine if the test failed.
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f'rep_{rep.when}', rep)
fail_dir = Path('gerbonara_test_failures')
def pytest_sessionstart(session):
if not hasattr(session.config, 'workerinput'): # on worker
return
# on coordinator
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
f.unlink()
try:
run_cargo_cmd('resvg', '--help')
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
def pytest_addoption(parser):
parser.addoption('--kicad-symbol-library', nargs='*', help='Run symbol library tests on given symbol libraries. May be given multiple times.')
parser.addoption('--kicad-footprint-files', nargs='*', help='Run footprint library tests on given footprint files. May be given multiple times.')
def pytest_generate_tests(metafunc):
if 'kicad_library_file' in metafunc.fixturenames:
if not (library_files := metafunc.config.getoption('symbol_library', None)):
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
lib_dir = Path(lib_dir).expanduser()
if not lib_dir.is_dir():
raise ValueError(f'Path "{lib_dir}" given by KICAD_SYMBOLS environment variable does not exist or is not a directory.')
library_files = list(lib_dir.glob('*.kicad_sym'))
else:
raise ValueError('Either --kicad-symbol-library command line parameter or KICAD_SYMBOLS environment variable must be given.')
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
if 'kicad_mod_file' in metafunc.fixturenames:
if not (mod_files := metafunc.config.getoption('footprint_files', None)):
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
lib_dir = Path(lib_dir).expanduser()
if not lib_dir.is_dir():
raise ValueError(f'Path "{lib_dir}" given by KICAD_FOOTPRINTS environment variable does not exist or is not a directory.')
mod_files = list(lib_dir.glob('*.pretty/*.kicad_mod'))
else:
raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given.')
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))

View file

@ -1,313 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2022 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 subprocess
from pathlib import Path
import tempfile
import textwrap
import os
import stat
from functools import total_ordering
import shutil
import bs4
from contextlib import contextmanager
import hashlib
import numpy as np
from PIL import Image
cachedir = Path(__file__).parent / 'image_cache'
cachedir.mkdir(exist_ok=True)
@total_ordering
class ImageDifference:
def __init__(self, value, histogram):
self.value = value
self.histogram = histogram
def __float__(self):
return float(self.value)
def __eq__(self, other):
return float(self) == float(other)
def __lt__(self, other):
return float(self) < float(other)
def __str__(self):
return str(float(self))
@total_ordering
class Histogram:
def __init__(self, value, size):
self.value, self.size = value, size
def __eq__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value == other).all()
def __lt__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value <= other).all()
def __getitem__(self, index):
return self.value[index]
def __str__(self):
return f'{list(self.value)} size={self.size}'
def run_cargo_cmd(cmd, args, **kwargs):
if cmd.upper() in os.environ:
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
try:
return subprocess.run([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, bg=None):
params = f'{dpi}{bg}'.encode()
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.png'
if not cachefile.is_file():
bg = 'black' if bg is None else bg
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
shutil.copy(cachefile, out_png)
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
params = f'{origin}{size}{fg}{bg}'.encode()
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.svg'
if not cachefile.is_file():
print(f'Building cache for {Path(in_gbr).name}')
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
# and project file color settings.
# TODO: File issue upstream.
with tempfile.NamedTemporaryFile('w') as f:
if override_unit_spec:
units, zeros, digits = override_unit_spec
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
units = 0 if units == 'inch' else 1
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
(list 'autodetect 'Boolean 0)
(list 'zero_suppression 'Enum {zeros})
(list 'units 'Enum {units})
(list 'digits 'Integer {digits})
))''')
else:
unit_spec = ''
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
f.flush()
if override_unit_spec:
shutil.copy(f.name, '/tmp/foo.gbv')
x, y = origin
w, h = size
cmd = ['gerbv', '-x', export_format,
'--border=0',
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
f'--background={bg}',
f'--foreground={fg}',
'-o', str(cachefile), '-p', f.name]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
print(f'Re-using cache for {Path(in_gbr).name}')
shutil.copy(cachefile, out_svg)
def kicad_fp_export(mod_file, out_svg):
mod_file = Path(mod_file)
if mod_file.suffix.lower() != '.kicad_mod':
raise ValueError("KiCad footprint file must have .kicad_mod extension for kicad-cli to do it's thing")
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.svg'
if not cachefile.is_file():
print(f'Building cache for {mod_file.name}')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
pretty_dir = mod_file.parent
fp_name = mod_file.name[:-len('.kicad_mod')]
cmd = ['podman', 'run', '--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
'registry.hub.docker.com/kicad/kicad:nightly',
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}']
subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
out_file = Path(tmpdir) / f'{fp_name}.svg'
shutil.copy(out_file, cachefile)
else:
print(f'Re-using cache for {mod_file.name}')
shutil.copy(cachefile, out_svg)
@contextmanager
def svg_soup(filename):
with open(filename, 'r') as f:
soup = bs4.BeautifulSoup(f.read(), 'xml')
yield soup
with open(filename, 'w') as f:
f.write(str(soup))
def cleanup_gerbv_svg(soup):
width = soup.svg["width"]
height = soup.svg["height"]
width = width[:-2] if width.endswith('pt') else width
height = height[:-2] if height.endswith('pt') else height
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
for group in soup.find_all('g'):
# gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit
# handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it
# seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders
# aperture macros into a new surface, which for some reason gets clipped by Cairo to the given
# 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']
def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
with svg_soup(ref_svg.name) as soup:
if svg_transform is not None:
svg = soup.svg
children = list(svg.children)
g = soup.new_tag('g', attrs={'transform': svg_transform})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup)
with svg_soup(act_svg.name) as soup:
cleanup_gerbv_svg(soup)
return svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
print(f'=== {var} ===')
print(Path(locals()[var].name).read_text().splitlines()[1])
with svg_soup(ref1_svg.name) as soup1:
if svg_transform1 is not None:
svg = soup1.svg
children = list(svg.children)
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup1)
with svg_soup(ref2_svg.name) as soup2:
if svg_transform2 is not None:
svg = soup2.svg
children = list(svg.children)
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup2)
defs1 = soup1.find('defs')
if not defs1:
defs1 = soup1.new_tag('defs')
soup1.find('svg').insert(0, defs1)
defs2 = soup2.find('defs')
if defs2:
defs2 = defs2.extract()
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
# iterating because we modify the tree in the loop body.
for c in list(defs2.contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
defs1.append(c)
for use in soup2.find_all('use', recursive=True):
if (href := use.get('xlink:href', '')).startswith('#'):
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
svg1 = soup1.find('svg')
for c in list(soup2.find('svg').contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
svg1.append(c)
if composite_out:
shutil.copyfile(ref1_svg.name, composite_out)
with svg_soup(act_svg.name) as soup:
cleanup_gerbv_svg(soup)
return svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
def svg_difference(reference, actual, diff_out=None, background=None, dpi=100):
with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\
tempfile.NamedTemporaryFile(suffix='-act.png') as act_png:
svg_to_png(reference, ref_png.name, bg=background, dpi=dpi)
svg_to_png(actual, act_png.name, bg=background, dpi=dpi)
return image_difference(ref_png.name, act_png.name, diff_out=diff_out)
def image_difference(reference, actual, diff_out=None):
ref = np.array(Image.open(reference)).astype(float)
out = np.array(Image.open(actual)).astype(float)
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
# TODO blur images here before comparison to mitigate aliasing issue
delta = np.abs(out - ref).astype(float) / 255
if diff_out:
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
return (ImageDifference(delta.mean(), hist),
ImageDifference(delta.max(), hist),
Histogram(hist, out.size))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,9 +0,0 @@
%FSLAX46Y46*%
%MOMM*%
%ADD10C,0.150000*%
%ADD11C,0.100000*%
%ADD12C,0.600000*%
%ADD13C,0.120000*%
%LPD*%
G54D10*X168523809Y-90902380D02*X168523809Y-89902380D01*X168285714Y-89902380D01*X168142857Y-89950000D01*X168047619Y-90045238D01*X168000000Y-90140476D01*X167952380Y-90330952D01*X167952380Y-90473809D01*X168000000Y-90664285D01*X168047619Y-90759523D01*X168142857Y-90854761D01*X168285714Y-90902380D01*X168523809Y-90902380D01*X167571428Y-90616666D02*X167095238Y-90616666D01*X167666666Y-90902380D02*X167333333Y-89902380D01*X167000000Y-90902380D01*X166809523Y-89902380D02*X166238095Y-89902380D01*X166523809Y-90902380D02*X166523809Y-89902380D01*X165904761Y-90378571D02*X165571428Y-90378571D01*X165428571Y-90902380D02*X165904761Y-90902380D01*X165904761Y-89902380D01*X165428571Y-89902380D01*X168509523Y-78304761D02*X168366666Y-78352380D01*X168128571Y-78352380D01*X168033333Y-78304761D01*X167985714Y-78257142D01*X167938095Y-78161904D01*X167938095Y-78066666D01*X167985714Y-77971428D01*X168033333Y-77923809D01*X168128571Y-77876190D01*X168319047Y-77828571D01*X168414285Y-77780952D01*X168461904Y-77733333D01*X168509523Y-77638095D01*X168509523Y-77542857D01*X168461904Y-77447619D01*X168414285Y-77400000D01*X168319047Y-77352380D01*X168080952Y-77352380D01*X167938095Y-77400000D01*X167509523Y-78352380D02*X167509523Y-77352380D01*X166938095Y-78352380D01*X166938095Y-77352380D01*G54D11*G36*X168500000Y-89450000D02*G01X128500000Y-89450000D01*X128500000Y-78950000D01*X168500000Y-78950000D01*X168500000Y-89450000D01*G37*X168500000Y-89450000D02*X128500000Y-89450000D01*X128500000Y-78950000D01*X168500000Y-78950000D01*X168500000Y-89450000D01*G54D12*X131250000Y-58357142D02*X130678571Y-58357142D01*X130392857Y-58500000D01*X130107142Y-58785714D01*X129964285Y-59357142D01*X129964285Y-60357142D01*X130107142Y-60928571D01*X130392857Y-61214285D01*X130678571Y-61357142D01*X131250000Y-61357142D01*X131535714Y-61214285D01*X131821428Y-60928571D01*X131964285Y-60357142D01*X131964285Y-59357142D01*X131821428Y-58785714D01*X131535714Y-58500000D01*X131250000Y-58357142D01*X128678571Y-58357142D02*X128678571Y-60785714D01*X128535714Y-61071428D01*X128392857Y-61214285D01*X128107142Y-61357142D01*X127535714Y-61357142D01*X127250000Y-61214285D01*X127107142Y-61071428D01*X126964285Y-60785714D01*X126964285Y-58357142D01*X125964285Y-58357142D02*X124250000Y-58357142D01*X125107142Y-61357142D02*X125107142Y-58357142D01*X150071428Y-61357142D02*X150071428Y-58357142D01*X148642857Y-61357142D02*X148642857Y-58357142D01*X146928571Y-61357142D01*X146928571Y-58357142D01*G54D13*X117000000Y-76450000D02*X117000000Y-77150000D01*X118200000Y-77150000D02*X118200000Y-76450000D01*G54D10*X120242857Y-77157142D02*X120290476Y-77204761D01*X120433333Y-77252380D01*X120528571Y-77252380D01*X120671428Y-77204761D01*X120766666Y-77109523D01*X120814285Y-77014285D01*X120861904Y-76823809D01*X120861904Y-76680952D01*X120814285Y-76490476D01*X120766666Y-76395238D01*X120671428Y-76300000D01*X120528571Y-76252380D01*X120433333Y-76252380D01*X120290476Y-76300000D01*X120242857Y-76347619D01*X119290476Y-77252380D02*X119861904Y-77252380D01*X119576190Y-77252380D02*X119576190Y-76252380D01*X119671428Y-76395238D01*X119766666Y-76490476D01*X119861904Y-76538095D01*X118338095Y-77252380D02*X118909523Y-77252380D01*X118623809Y-77252380D02*X118623809Y-76252380D01*X118719047Y-76395238D01*X118814285Y-76490476D01*X118909523Y-76538095D01*X0Y0D02*M00*

View file

@ -1,69 +0,0 @@
from itertools import zip_longest
import re
from ..cad.kicad.sexp import build_sexp
from ..cad.kicad.sexp_mapper import sexp
from ..cad.kicad.symbols import Library
from .utils import tmpfile
def test_parse(kicad_library_file):
Library.open(kicad_library_file)
def test_round_trip(kicad_library_file, tmpfile):
print('========== Stage 1 load ==========')
orig_lib = Library.open(kicad_library_file)
print('========== Stage 1 save ==========')
stage1_sexp = build_sexp(orig_lib.sexp())
print('========== Stage 2 load ==========')
reparsed_lib = Library.parse(stage1_sexp)
print('========== Stage 2 save ==========')
stage2_sexp = build_sexp(reparsed_lib.sexp())
print('========== Checks ==========')
for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
assert stage1 == stage2
original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_library_file.read_text()))
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
tmpfile('Processed original', '.kicad_sym').write_text(original)
stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
tmpfile('Processed stage 1 output', '.kicad_sym').write_text(stage1)
for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
if original.startswith('(version'):
continue
original, stage1 = original.strip(), stage1.strip()
if original != stage1:
if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
# These files have symbols with graphic primitives in non-standard order
return
if original.startswith('(offset') and stage1.startswith('(offset'):
# Some symbol files contain ints where floats should be.
return
if original.startswith('(symbol') and stage1.startswith('(symbol'):
# Re-export can change symbol order. This is ok.
return
if original.startswith('(at') and stage1.startswith('(at'):
# There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
return
if 'hide' in original or 'hide' in stage1:
# KiCad changed the position of the hide token inside text effects between versions.
return
assert original == stage1

72
pyproject.toml Normal file
View file

@ -0,0 +1,72 @@
[project]
name = "gerbonara"
version = "1.6.2"
description = "Tools to handle Gerber and Excellon files in Python"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.12"
dependencies = ["click", "rtree", "quart"]
authors = [
{ name = "jaseg" },
{ name = "XenGi" },
]
maintainers = [
{ name = "Gerbonara maintainers", email = "gerbonara@jaseg.de" },
]
keywords = ["gerber", "excellon", "pcb", "RS274x", "EDA"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: Manufacturing",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License",
"Topic :: Artistic Software",
"Topic :: Multimedia :: Graphics",
"Topic :: Printing",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
"Topic :: Scientific/Engineering :: Image Processing",
"Topic :: Utilities",
]
[project.urls]
Homepage = "https://jaseg.de/projects/gerbonara/"
Documentation = "https://gerbolyze.gitlab.io/gerbonara/"
Source = "https://git.jaseg.de/gerbonara.git"
Tracker = "https://gitlab.com/gerbolyze/gerbonara/issues"
[project.scripts]
gerbonara = "gerbonara.cli:cli"
protoserve = "gerbonara.cad.protoserve:main"
[dependency-groups]
dev = [
"pytest",
"pytest-xdist",
"numpy",
"scipy",
"tqdm",
"beautifulsoup4",
"lxml",
"pillow"
]
[build-system]
requires = ["uv-build"]
build-backend = "uv_build"
[tool.pytest]
testpaths = ["tests"]
norecursedirs = ["*"]
kicad_symbols_tag = "9.0.6"
kicad_footprints_tag = "9.0.6"
kicad_source_tag = "9.0.6"
# Tag to use for container for footprint svg export
# For a list of available tags, see https://hub.docker.com/r/kicad/kicad/tags
kicad_container_tag = "9.0.6-full"

View file

@ -1,3 +0,0 @@
[pytest]
testpaths = gerbonara/tests
norecursedirs=*

View file

@ -1,65 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
from setuptools import setup, find_packages
import subprocess
def version():
try:
res = subprocess.run(['git', 'describe', '--tags', '--match', 'v*'], capture_output=True, check=True, text=True)
version, _, _rest = res.stdout.strip()[1:].partition('-')
return version
except:
subprocess.run(['git', 'describe', '--tags', '--match', 'v*'])
raise
setup(
name='gerbonara',
version=version(),
author='jaseg, XenGi',
author_email='gerbonara@jaseg.de',
description='Tools to handle Gerber and Excellon files in Python',
long_description=Path('README.md').read_text(),
long_description_content_type='text/markdown',
url='https://gitlab.com/gerbolyze/gerbonara',
project_urls={
'Documentation': 'https://gerbolyze.gitlab.io/gerbonara/',
# 'Funding': 'https://donate.pypi.org',
# 'Say Thanks!': 'http://saythanks.io/to/example',
'Source': 'https://gitlab.com/gerbolyze/gerbonara',
'Tracker': 'https://gitlab.com/gerbolyze/gerbonara/issues',
},
packages=find_packages(exclude=['tests']),
install_requires=['click', 'rtree'],
entry_points={
'console_scripts': [
'gerbonara = gerbonara.cli:cli',
],
},
classifiers=[
'Development Status :: 4 - Beta',
#'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: Manufacturing',
'Intended Audience :: Science/Research',
'License :: OSI Approved :: Apache Software License',
'Natural Language :: English',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Artistic Software',
'Topic :: Multimedia :: Graphics',
'Topic :: Printing',
'Topic :: Scientific/Engineering',
'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)',
'Topic :: Scientific/Engineering :: Image Processing',
'Topic :: Utilities',
'Typing :: Typed',
],
keywords='gerber excellon pcb',
python_requires='>=3.10',
)

View file

@ -30,5 +30,6 @@ from .excellon import ExcellonFile
from .ipc356 import Netlist
from .layers import LayerStack
from .utils import MM, Inch
from importlib.metadata import version
__version__ = '1.2.0'
__version__ = version('gerbonara')

View file

@ -0,0 +1,18 @@
from .parse import ApertureMacro, GenericMacros
from .expression import (Expression,
UnitExpression,
ConstantExpression,
VariableExpression,
ParameterExpression,
NegatedExpression,
OperatorExpression)
from .primitive import (Comment,
Circle,
VectorLine,
CenterLine,
Outline,
Polygon,
Moire,
Thermal)

View file

@ -62,7 +62,7 @@ class Expression:
return expr(other) / self
def __neg__(self):
return NegatedExpression(self)
return NegatedExpression(self).optimized()
def __pos__(self):
return self
@ -180,6 +180,9 @@ class ConstantExpression(Expression):
@dataclass(frozen=True, slots=True)
class VariableExpression(Expression):
''' An expression that encapsulates some other complex expression and will replace all occurences of it with a newly
allocated variable at export time.
'''
expr: Expression
def optimized(self, variable_binding={}):
@ -201,6 +204,7 @@ class VariableExpression(Expression):
@dataclass(frozen=True, slots=True)
class ParameterExpression(Expression):
''' An expression that refers to a macro variable or parameter '''
number: int
def optimized(self, variable_binding={}):
@ -335,6 +339,12 @@ class OperatorExpression(Expression):
# -x [*/] -y == x [*/] y
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, NegatedExpression(r)):
rv = op(l, r)
# -x [*/] y == -(x [*/] y)
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, r):
rv = NegatedExpression(op(l, r))
# x [*/] -y == -(x [*/] y)
case (l, (operator.truediv | operator.mul) as op, NegatedExpression(r)):
rv = NegatedExpression(op(l, r))
# x + -y == x - y
case (l, operator.add, NegatedExpression(r)):
rv = l-r

View file

@ -0,0 +1,463 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
from dataclasses import dataclass, field, replace, fields
import operator
import re
import ast
import copy
import warnings
import math
from . import primitive as ap
from .expression import *
from ..apertures import ApertureMacroInstance
from ..utils import MM
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
def rad_to_deg(x):
return (x / math.pi) * 180
def _map_expression(node, variables={}, parameters=set()):
if isinstance(node, ast.Constant):
return ConstantExpression(node.value)
elif isinstance(node, ast.BinOp):
op_map = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
return OperatorExpression(op_map[type(node.op)],
_map_expression(node.left, variables, parameters),
_map_expression(node.right, variables, parameters))
elif isinstance(node, ast.UnaryOp):
if type(node.op) == ast.UAdd:
return _map_expression(node.operand, variables, parameters)
else:
return NegatedExpression(_map_expression(node.operand, variables, parameters))
elif isinstance(node, ast.Name):
num = int(node.id[3:]) # node.id has format var[0-9]+
if num in variables:
return VariableExpression(variables[num])
else:
parameters.add(num)
return ParameterExpression(num)
else:
raise SyntaxError('Invalid aperture macro expression')
def _parse_expression(expr, variables, parameters):
expr = expr.lower().replace('x', '*')
expr = re.sub(r'\$([0-9]+)', r'var\1', expr)
try:
parsed = ast.parse(expr, mode='eval').body
except SyntaxError as e:
raise SyntaxError('Invalid aperture macro expression') from e
return _map_expression(parsed, variables, parameters)
@dataclass(frozen=True, slots=True)
class ApertureMacro:
""" Definition of an aperture macro in a Gerber file.
An aperture macro is a collection of shape primitives that are flashed all at once. The properties of these
primitives such as their relative position and size can be given explicitly, or can be given as a basic
arithmetic expression (so +/-/*/:, no higher functions) based on parameters. After the macro is defined in the
Gerber file, it is *bound* to a particular set of parameter values in an aperture definition. One macro can be
used by zero, or by multiple aperture definitions. To flash a macro, you must first bind it in an aperture
definition, which can then be flash'ed.
Gerbonara calls these apertures that bind a macro :py:class:`~..apertures.ApertureMacroInst`. You can bind a
macro to a set of parameters by calling it:
.. code-block: python
# am is some instance of ApertureMacro
aperture_def = am(1, 2, 3)
gerber.objects.append(Flash(x=12, y=34, aperture=aperture_def))
Internally, the aperture macro API uses millimeters though most functions allow you to pass an unit parameter.
When you want to programmatically create aperture macros, we recommend using :py:meth:`~.ApertureMacro.map` on a
dataclass-like class definition. Have a look at this code from :py:class:`~.GenericMacros`:
.. code-block: python
@ApertureMacro.map('GNR')
class rect:
w: float # width
h: float # height
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
# rect now is an instance of ApertureMacro
After this, you can bind this macro to an aperture by calling it. When you use this dataclass-like syntax,
keyword arguments are supported, and default values work like with normal dataclasses:
.. code-block: python
# returns an instance of ApertureMacroInstance containing the given parameters
my_rect = GenericMacros.rect(w=12, h=34)
gerber.objects.append(Flash(x=12, y=34, aperture=my_rect))
.. important::
Use your own programmatically defined aperture macros sparingly. While support is getting better, many
tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling
aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic
expressions, make sure to carefully check the manufacturing files provided by your PCB fab.
gerbonara currently handles embedded arithmetic expressions by *always* calculating them out since we have
recently seen high-end commercial tooling failing at issues as basic as operator precedence. This increases
file sizes very very slightly, but it makes sure that you get correct results.
This means that you can use gerbonara to calculate out aperture macros and hard-bake their values into the
gerber source. This can be useful if you have a file that includes complex macros that some manufacturer's
tooling can't handle on its own.
"""
name: str = field(default=None, hash=False, compare=False)
num_parameters: int = 0
primitives: tuple = ()
comments: tuple = field(default=(), hash=False, compare=False)
_param_dataclass: object = field(default=None, hash=False, compare=False)
def __post_init__(self):
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name):
# We can't use field(default_factory=...) here because that factory doesn't get a reference to the instance.
self._reset_name()
def _reset_name(self):
object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
@classmethod
def map(our_kls, macro_name=None):
def wrapper(kls):
nonlocal our_kls, macro_name
dc = dataclass(kls)
# Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression,
# then draw() it to get a list of bound macro primitives.
primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw())
name = macro_name if macro_name else f'GNM{kls.__name__}'
# Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names
if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name):
raise ValueError(f'Name {name!r} is invalid as an aperture macro name')
return our_kls(
name = name,
num_parameters = len(fields(dc)),
primitives = primitives,
comments = [l.strip() for l in dc.__doc__.strip().splitlines()],
_param_dataclass = dc)
return wrapper
def __call__(self, *args, unit=MM, **kwargs):
if self._param_dataclass:
# Above, in map(), we construct the dataclass with the ParameterExpression(i) as params to draw the macro
# primitives. Here, we construct it with the user's supplied concrete numeric parameters instead, and then
# extract a list of these parameters. This should work great as long as the user doesn't get too fancy with
# dataclass metaprogramming hackery.
bound = self._param_dataclass(*args, **kwargs)
return ApertureMacroInstance(macro=self, parameters=tuple(getattr(bound, f.name) or 0 for f in fields(bound)), unit=unit)
@classmethod
def parse_macro(kls, macro_name, body, unit):
comments = []
variables = {}
parameters = set()
primitives = []
blocks = body.split('*')
for block in blocks:
if not (block := block.strip()): # empty block
continue
if block.startswith('0 '): # comment
comments.append(block[2:])
continue
block = re.sub(r'\s', '', block)
if block[0] == '$': # variable definition
try:
name, _, expr = block.partition('=')
number = int(name[1:])
if number in variables:
warnings.warn(f'Re-definition of aperture macro variable ${number} inside aperture macro "{macro_name}". Previous definition of ${number} was ${variables[number]}.')
variables[number] = _parse_expression(expr, variables, parameters)
except Exception as e:
raise SyntaxError(f'Error parsing variable definition {block!r}') from e
else: # primitive
primitive, *args = block.split(',')
args = [ _parse_expression(arg, variables, parameters) for arg in args ]
try:
primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args))
except KeyError as e:
raise SyntaxError(f'Unknown aperture macro primitive code {int(primitive)}')
return kls(macro_name, max(parameters, default=0), tuple(primitives), tuple(comments))
def __str__(self):
return f'<Aperture macro {self.name}, primitives {self.primitives}>'
def __repr__(self):
return str(self)
def dilated(self, offset, unit=MM):
new_primitives = []
for primitive in self.primitives:
try:
if primitive.exposure.calculate():
new_primitives += primitive.dilated(offset, unit)
except IndexError:
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
pass
return replace(self, primitives=tuple(new_primitives))
def substitute_params(self, params, unit=None, macro_name=None):
params = dict(enumerate(params, start=1))
return replace(self,
num_parameters=0,
name=macro_name,
primitives=tuple(p.substitute_params(params, unit) for p in self.primitives),
comments=(f'Fully substituted instance of {self.name} macro',
f'Original parameters: {"X".join(map(str, params.values())) if params else "none"}'))
def to_gerber(self, settings):
""" Serialize this macro's content (without the name) into Gerber using the given file unit """
comments = [ f'0 {c.replace("*", "_").replace("%", "_")}' for c in self.comments ]
subexpression_variables = {}
def register_variable(expr):
expr_str = expr.to_gerber(register_variable, settings.unit)
if expr_str not in subexpression_variables:
subexpression_variables[expr_str] = self.num_parameters + 1 + len(subexpression_variables)
return subexpression_variables[expr_str]
primitive_defs = [prim.to_gerber(register_variable, settings) for prim in self.primitives]
variable_defs = [f'${num}={expr_str}' for expr_str, num in subexpression_variables.items()]
return '*\n'.join(comments + variable_defs + primitive_defs)
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
parameters = dict(enumerate(parameters, start=1))
for primitive in self.primitives:
yield from primitive.to_graphic_primitives(offset, rotation, parameters, unit, polarity_dark)
def rotated(self, angle):
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
return replace(self, primitives=tuple(
replace(primitive, rotation=primitive.rotation - rad_to_deg(angle)) for primitive in self.primitives))
def scaled(self, scale):
return replace(self, primitives=tuple(
primitive.scaled(scale) for primitive in self.primitives))
var = ParameterExpression
deg_per_rad = 180 / math.pi
class GenericMacros:
"""NOTE:
All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing API.
"""
@ApertureMacro.map('GNC')
class circle:
""" Filled circle macro with an optional round hole
:param float diameter: Diameter of the circle
:param hole_dia: Diameter of the hole (optional)
"""
diameter: float
hole_dia: float = 0
def draw(self):
yield ap.Circle('mm', 1, self.diameter, 0, 0)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GNR')
class rect:
""" Axis-aligned rectangle with an optional round center hole.
:param float w: Width
:param float h: Height
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float # width
h: float # height
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GRR')
class rounded_rect:
""" Rectangle with circular arc corners and an optional round center hole.
:param float w: Width
:param float h: Height
:param float r: Corner radius
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float # width
h: float # height
r: float # Corner radius
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.CenterLine('mm', 1, self.w-2*self.r, self.h, 0, 0, self.rotation * -deg_per_rad)
yield ap.CenterLine('mm', 1, self.w, self.h-2*self.r, 0, 0, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GTR')
class isosceles_trapezoid:
""" Isosceles trapezoid with a wider bottom edge and narrower top edge, with an optional round center hole.
:param float w: Width of the bottom (wider) edge
:param float h: Height
:param float d: Length difference between bottom and top edges; top width = w - d
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float # width
h: float # height
d: float # length difference between narrow side (top) and wide side (bottom)
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.Outline('mm', 1, 4,
(self.w/-2, self.h/-2,
self.w/-2+self.d/2, self.h/2,
self.w/2-self.d/2, self.h/2,
self.w/2, self.h/-2,
self.w/-2, self.h/-2,),
self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GRTR')
class rounded_isosceles_trapezoid:
""" Isosceles trapezoid with rounded corners and an optional round center hole. Unlike the rounded rectangle, the shape is defined by first defining a non-rounded trapezoid, which is then offet to the outside by the given margin.
:param float w: Width of the bottom (wider) edge
:param float h: Height
:param float d: Length difference between bottom and top edges; top width = w - d
:param float margin: Corner rounding radius
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float
h: float
d: float # length difference between narrow side (top) and wide side (bottom)
margin: float
hole_dia: float = 0
rotation: float = 0
def draw(self):
rot = self.rotation * -deg_per_rad
yield ap.Outline('mm', 1, 4,
(self.w/-2, self.h/-2,
self.w/-2+self.d/2, self.h/2,
self.w/2-self.d/2, self.h/2,
self.w/2, self.h/-2,
self.w/-2, self.h/-2,),
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/-2, self.h/-2,
self.w/-2+self.d/2, self.h/2,
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/-2+self.d/2, self.h/2,
self.w/2-self.d/2, self.h/2,
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/2-self.d/2, self.h/2,
self.w/2, self.h/-2,
rot)
yield ap.VectorLine('mm', 1, self.margin*2,
self.w/2, self.h/-2,
self.w/-2, self.h/-2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/-2, self.h/-2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/-2+self.d/2, self.h/2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/2-self.d/2, self.h/2,
rot)
yield ap.Circle('mm', 1, self.margin*2,
self.w/2, self.h/-2,
rot)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GNO')
class obround:
""" Rectangle with semicircular end caps (stadium shape), with an optional round center hole. The long axis is along the X axis when rotation is zero.
:param float w: Total width including end caps; must satisfy w >= h
:param float h: Height, equal to the end cap diameter
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
w: float
h: float
hole_dia: float = 0
rotation: float = 0
def draw(self):
rot = self.rotation * -deg_per_rad
yield ap.CenterLine('mm', 1, self.w - self.h, self.h, 0, 0, rot)
yield ap.Circle('mm', 1, self.h, +(self.w-self.h)/2, 0, rot)
yield ap.Circle('mm', 1, self.h, -(self.w-self.h)/2, 0, rot)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
@ApertureMacro.map('GNP')
class polygon:
""" Regular n-sided polygon with an optional round center hole.
:param int n: Number of sides
:param float diameter: Diameter of the circumscribed circle
:param float hole_dia: Diameter of the round hole (optional)
:param float rotation: Rotation in clockwise radians (optional)
"""
n: int
diameter: float
hole_dia: float = 0
rotation: float = 0
def draw(self):
yield ap.Polygon('mm', 1, self.diameter, 0, 0, self.n, self.rotation * -deg_per_rad)
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
if __name__ == '__main__':
import sys
#for line in sys.stdin:
#expr = _parse_expression(line.strip())
#print(expr, '->', expr.optimized())
for primitive in parse_macro(sys.stdin.read(), 'mm'):
print(primitive)

View file

@ -97,14 +97,18 @@ class Circle(Primitive):
exposure : Expression
diameter : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
x : UnitExpression = 0
y : UnitExpression = 0
rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
x, y = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
x, y = x+offset[0], y+offset[1]
if math.isclose(calc.diameter, 0):
return []
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def substitute_params(self, binding, unit):
@ -144,6 +148,9 @@ class VectorLine(Primitive):
center_x, center_y = center_x+offset[0], center_y+offset[1]
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
if math.isclose(calc.width, 0):
return []
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
@ -182,6 +189,9 @@ class CenterLine(Primitive):
x, y = x+offset[0], y+offset[1]
w, h = calc.width, calc.height
if math.isclose(calc.width, 0) or math.isclose(calc.height, 0):
return []
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def substitute_params(self, binding, unit):
@ -217,7 +227,8 @@ class Polygon(Primitive):
rotation += deg_to_rad(calc.rotation)
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
print('xy', calc.x, calc.y)
return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilated(self, offset, unit):
@ -251,6 +262,9 @@ class Moire(Primitive):
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
if math.isclose(calc.d_outer, 0):
return []
pitch = calc.line_thickness + calc.gap_w
for i in range(int(round(calc.num_circles))):
yield gp.Circle(x, y, calc.d_outer/2 - i*pitch, polarity_dark=True)
@ -280,7 +294,7 @@ class Moire(Primitive):
@dataclass(frozen=True, slots=True)
class Thermal(Primitive):
code = 7
exposure : Expression
# Note: Thermal primitives according to spec don't have an exposure variable
# center x/y
x : UnitExpression
y : UnitExpression
@ -295,13 +309,16 @@ class Thermal(Primitive):
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
dark = (bool(calc.exposure) == polarity_dark)
dark = True
if math.isclose(calc.d_outer, 0):
return []
return [
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark),
gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
gp.Rectangle(x, y, calc.d_outer, calc.gap_w, rotation=rotation, polarity_dark=not dark),
gp.Rectangle(x, y, calc.gap_w, calc.d_outer, rotation=rotation, polarity_dark=not dark),
]
def dilate(self, offset, unit):
@ -383,6 +400,10 @@ class Outline(Primitive):
bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ]
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
bound_radii = [None] * len(bound_coords)
if len(bound_coords) < 3:
return []
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
def dilated(self, offset, unit):

View file

@ -21,7 +21,6 @@ import math
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
from functools import lru_cache
from .aperture_macros.parse import GenericMacros
from .utils import LengthUnit, MM, Inch, sum_bounds
from . import graphic_primitives as gp
@ -160,7 +159,8 @@ class ExcellonTool(Aperture):
return self
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
from .aperture_macros.parse import GenericMacros
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
def _params(self, unit=None):
return (self.unit.convert_to(unit, self.diameter),)
@ -205,7 +205,9 @@ class CircleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
from .aperture_macros.parse import GenericMacros
return GenericMacros.circle(MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit))
def _params(self, unit=None):
return _strip_right(
@ -260,12 +262,11 @@ class RectangleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.rect,
(MM(self.w, self.unit),
MM(self.h, self.unit),
MM(self.hole_dia, self.unit) or 0,
0,
rotation))
from .aperture_macros.parse import GenericMacros
return GenericMacros.rect(MM(self.w, self.unit),
MM(self.h, self.unit),
MM(self.hole_dia, self.unit),
rotation)
def _params(self, unit=None):
return _strip_right(
@ -329,12 +330,11 @@ class ObroundAperture(Aperture):
rotation -= -math.pi/2
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
return ApertureMacroInstance(GenericMacros.obround,
(MM(inst.w, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0,
0,
rotation))
from .aperture_macros.parse import GenericMacros
return GenericMacros.obround(MM(inst.w, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0,
rotation)
def _params(self, unit=None):
return _strip_right(
@ -390,12 +390,18 @@ class PolygonAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self):
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
from .aperture_macros.parse import GenericMacros
return GenericMacros.polygon(self.n_vertices,
MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit),
self.rotation)
def _params(self, unit=None):
rotation = self.rotation % (2*math.pi / self.n_vertices)
if math.isclose(rotation, 0, abs_tol=1e-6):
rotation = None
else:
rotation = math.degrees(rotation)
if self.hole_dia is not None:
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
@ -456,7 +462,7 @@ class ApertureMacroInstance(Aperture):
# We do this because here we do not have information about which parameter has which physical units.
parameters = self.parameters
if len(parameters) > self.macro.num_parameters:
warnings.warn('Aperture definition using macro {self.macro.name} has more parameters than the macro uses.')
warnings.warn(f'Aperture definition using macro {self.macro.name} has more parameters than the macro uses.')
parameters = parameters[:self.macro.num_parameters]
return tuple(parameters)

View file

@ -0,0 +1 @@

View file

@ -9,7 +9,8 @@ from itertools import cycle
from .sexp import *
from .sexp_mapper import *
from ...newstroke import Newstroke
from ...utils import rotate_point, Tag, MM
from ...utils import rotate_point, sum_bounds, Tag, MM
from ...layers import LayerStack
from ... import apertures as ap
from ... import graphic_objects as go
@ -37,11 +38,40 @@ LAYER_MAP_K2G = {
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
class BBoxMixin:
def bounding_box(self, unit=MM):
if not hasattr(self, '_bounding_box'):
(min_x, min_y), (max_x, max_y) = sum_bounds(fe.bounding_box(unit) for fe in self.render())
# Convert back from gerbonara's coordinates to kicad coordinates.
self._bounding_box = (min_x, -max_y), (max_x, -min_y)
return self._bounding_box
@sexp_type('uuid')
class UUID:
value: str = field(default_factory=uuid.uuid4)
def __deepcopy__(self, memo):
return UUID()
def __after_parse__(self, parent):
self.value = str(self.value)
def before_sexp(self):
self.value = str(self.value)
def bump(self):
self.value = uuid.uuid4()
@sexp_type('group')
class Group:
locked: Flag() = False
name: str = ""
id: Named(str) = ""
members: Named(List(str)) = field(default_factory=list)
id: Named(str) = None
uuid: UUID = field(default_factory=UUID)
members: Named(Array(str)) = field(default_factory=list)
@sexp_type('color')
@ -94,10 +124,22 @@ class Stroke:
return attrs
@sexp_type('fill')
class Fill:
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background, Atom.color)) = Atom.none
color: Color = None
class WidthMixin:
def __post_init__(self):
if self.width is not None:
self.stroke = Stroke(self.width)
class Dasher:
def __init__(self, obj):
if obj.stroke:
w = obj.stroke.width if obj.stroke.width is not None else 0.254
w = obj.stroke.width if obj.stroke.width not in (None, 0, 0.0) else 0.254
t = obj.stroke.type
else:
w = obj.width or 0
@ -241,7 +283,14 @@ class XYCoord:
@sexp_type('pts')
class PointList:
xy : List(XYCoord) = field(default_factory=list)
@classmethod
def __map__(kls, obj, parent=None, path=''):
_tag, *values = obj
return [map_sexp(XYCoord, elem, parent=parent, path=path) for elem in values]
@classmethod
def __sexp__(kls, value):
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
@sexp_type('arc')
@ -254,15 +303,38 @@ class Arc:
@sexp_type('pts')
class ArcPointList:
@classmethod
def __map__(kls, obj, parent=None):
def __map__(kls, obj, parent=None, path=''):
_tag, *values = obj
return [map_sexp((XYCoord if elem[0] == 'xy' else Arc), elem, parent=parent) for elem in values]
return [map_sexp((XYCoord if elem[0] == 'xy' else Arc), elem, parent=parent, path=path) for elem in values]
@classmethod
def __sexp__(kls, value):
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
@sexp_type('net')
class Net:
index: int = 0
name: str = ''
class NetMixin:
def reset_net(self):
self.net = Net()
@property
def net_index(self):
if self.net is None:
return 0
return self.net.index
@property
def net_name(self):
if self.net is None:
return ''
return self.net.name
@sexp_type('xyz')
class XYZCoord:
x: float = 0
@ -298,8 +370,8 @@ class FontSpec:
face: Named(str) = None
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
thickness: Named(float) = None
bold: Flag() = False
italic: Flag() = False
bold: OmitDefault(Named(LegacyCompatibleFlag())) = False
italic: OmitDefault(Named(LegacyCompatibleFlag())) = False
line_spacing: Named(float) = None
@ -327,8 +399,8 @@ class Justify:
@sexp_type('effects')
class TextEffect:
font: FontSpec = field(default_factory=FontSpec)
hide: Flag() = False
justify: OmitDefault(Justify) = field(default_factory=Justify)
hide: OmitDefault(Named(LegacyCompatibleFlag())) = False
class TextMixin:
@ -469,23 +541,6 @@ class Timestamp:
self.value = uuid.uuid4()
@sexp_type('uuid')
class UUID:
value: str = field(default_factory=uuid.uuid4)
def __deepcopy__(self, memo):
return UUID()
def __after_parse__(self, parent):
self.value = str(self.value)
def before_sexp(self):
self.value = Atom(str(self.value))
def bump(self):
self.value = uuid.uuid4()
@sexp_type('tedit')
class EditTime:
value: str = field(default_factory=time.time)
@ -522,11 +577,13 @@ class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
at: AtPos = None
unlocked: OmitDefault(Named(YesNoAtom())) = True
layer: Named(str) = None
hide: Flag() = False
hide: OmitDefault(Named(YesNoAtom())) = False
uuid: UUID = None
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
effects: OmitDefault(TextEffect) = field(default_factory=TextEffect)
_ : SEXP_END = None
parent: object = None
@ -543,6 +600,14 @@ class DrawnProperty(TextMixin):
self.value = value
@sexp_type('chamfer')
class Chamfer:
top_left: Flag() = False
top_right: Flag() = False
bottom_left: Flag() = False
bottom_right: Flag() = False
if __name__ == '__main__':
class Foo:
pass

View file

@ -21,6 +21,7 @@ from . import graphical_primitives as gr
from ..primitives import Positioned
from ... import __version__
from ... import graphic_primitives as gp
from ... import graphic_objects as go
from ... import apertures as ap
@ -54,8 +55,9 @@ class Text:
type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
text: str = ""
at: AtPos = field(default_factory=AtPos)
unlocked: Flag() = False
unlocked: OmitDefault(Named(YesNoAtom())) = False
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
hide: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
tstamp: Timestamp = None
@ -72,12 +74,15 @@ class TextBox:
locked: Flag() = False
text: str = None
start: Rename(XYCoord) = None
end: Named(XYCoord) = None
end: Rename(XYCoord) = None
margins: Rename(gr.Margins) = None
pts: PointList = None
angle: Named(float) = 0.0
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
border: Named(YesNoAtom()) = False
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
@ -90,6 +95,7 @@ class Line:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False
@ -113,9 +119,10 @@ class Rectangle:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
fill: gr.FillMode = None
locked: Flag() = False
tstamp: Timestamp = None
@ -146,9 +153,10 @@ class Circle:
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
fill: gr.FillMode = None
locked: Flag() = False
tstamp: Timestamp = None
@ -182,8 +190,10 @@ class Arc:
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
width: Named(float) = None
angle: Named(float) = None
stroke: Stroke = None
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
locked: Flag() = False
tstamp: Timestamp = None
@ -235,22 +245,23 @@ class Arc:
@sexp_type('fp_poly')
class Polygon:
pts: PointList = field(default_factory=PointList)
pts: PointList = field(default_factory=list)
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
fill: gr.FillMode = None
locked: Flag() = False
tstamp: Timestamp = None
def render(self, variables=None, cache=None):
if len(self.pts.xy) < 2:
if len(self.pts) < 2:
return
dasher = Dasher(self)
start = self.pts.xy[0]
start = self.pts[0]
dasher.move(start.x, start.y)
for point in self.pts.xy[1:]:
for point in self.pts[1:]:
dasher.line(point.x, point.y)
if dasher.width > 0:
@ -259,13 +270,14 @@ class Polygon:
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
if self.fill == Atom.solid:
yield go.Region([(pt.x, -pt.y) for pt in self.pts.xy], unit=MM)
yield go.Region([(pt.x, -pt.y) for pt in self.pts], unit=MM)
@sexp_type('fp_curve')
class Curve:
pts: PointList = field(default_factory=PointList)
pts: PointList = field(default_factory=list)
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False
@ -275,46 +287,6 @@ class Curve:
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
@sexp_type('format')
class DimensionFormat:
prefix: Named(str) = None
suffix: Named(str) = None
units: Named(int) = 3
units_format: Named(int) = 0
precision: Named(int) = 3
override_value: Named(str) = None
suppress_zeros: Flag() = False
@sexp_type('style')
class DimensionStyle:
thickness: Named(float) = None
arrow_length: Named(float) = None
text_position_mode: Named(int) = 0
extension_height: Named(float) = None
text_frame: Named(int) = 0
extension_offset: Named(str) = None
keep_text_aligned: Flag() = False
@sexp_type('dimension')
class Dimension:
locked: Flag() = False
type: AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial) = None
layer: Named(str) = None
tstamp: Timestamp = None
pts: PointList = field(default_factory=PointList)
height: Named(float) = None
orientation: Named(int) = 0
leader_length: Named(float) = None
gr_text: Named(Text) = None
format: DimensionFormat = field(default_factory=DimensionFormat)
style: DimensionStyle = field(default_factory=DimensionStyle)
def render(self, variables=None, cache=None):
raise NotImplementedError()
@sexp_type('drill')
class Drill:
oval: Flag() = False
@ -323,12 +295,6 @@ class Drill:
offset: Rename(XYCoord) = None
@sexp_type('net')
class NetDef:
number: int = None
name: str = None
@sexp_type('options')
class CustomPadOptions:
clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline
@ -345,7 +311,7 @@ class CustomPadPrimitives:
polygons: List(gr.Polygon) = field(default_factory=list)
curves: List(gr.Curve) = field(default_factory=list)
width: Named(float) = None
fill: Named(YesNoAtom()) = True
fill: gr.FillMode = True
def all(self):
yield from self.lines
@ -356,16 +322,8 @@ class CustomPadPrimitives:
yield from self.curves
@sexp_type('chamfer')
class Chamfer:
top_left: Flag() = False
top_right: Flag() = False
bottom_left: Flag() = False
bottom_right: Flag() = False
@sexp_type('pad')
class Pad:
class Pad(NetMixin):
number: str = None
type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None
shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None
@ -375,19 +333,22 @@ class Pad:
drill: Drill = None
layers: Named(Array(str)) = field(default_factory=list)
properties: List(Property) = field(default_factory=list)
remove_unused_layers: Wrap(Flag()) = False
keep_end_layers: Wrap(Flag()) = False
remove_unused_layers: Named(YesNoAtom()) = False
keep_end_layers: Named(YesNoAtom()) = False
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
uuid: UUID = field(default_factory=UUID)
rect_delta: Rename(XYCoord) = None
roundrect_rratio: Named(float) = None
thermal_bridge_angle: Named(int) = 45
thermal_bridge_width: Named(float) = 0.5
chamfer_ratio: Named(float) = None
chamfer: Chamfer = None
net: NetDef = None
net: Net = None
tstamp: Timestamp = None
pin_function: Named(str) = None
pintype: Named(str) = None
pinfunction: Named(str) = None
teardrops: gr.TeardropSpec = None
die_length: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin: Named(float) = None
@ -397,9 +358,10 @@ class Pad:
thermal_width: Named(float) = None
thermal_gap: Named(float) = None
options: OmitDefault(CustomPadOptions) = None
padstack: gr.PadStack = None
primitives: OmitDefault(CustomPadPrimitives) = None
_: SEXP_END = None
footprint: object = None
footprint: object = field(repr=False, default=None)
def __after_parse__(self, parent=None):
self.layers = unfuck_layers(self.layers)
@ -461,11 +423,11 @@ class Pad:
elif self.shape == Atom.rect:
if margin > 0:
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
(self.size.x+2*margin, self.size.y+2*margin,
margin,
0, 0, # no hole
rotation), unit=MM)
return GenericMacros.rounded_rect(self.size.x+2*margin,
self.size.y+2*margin,
margin,
0, # no hole
rotation)
else:
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation)
@ -492,28 +454,29 @@ class Pad:
# Note: KiCad already uses MM units, so no conversion needed here.
alpha = math.atan(y / dy) if dy > 0 else 0
return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid,
(x+dy+2*margin*math.cos(alpha), y+2*margin,
2*dy,
0, 0, # no hole
-rotation + math.pi), unit=MM)
return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha),
y+2*margin,
2*dy,
0, # no hole
-rotation + math.pi)
else:
return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid,
(x+dy, y,
2*dy, margin,
0, 0, # no hole
-rotation + math.pi), unit=MM)
return GenericMacros.rounded_isosceles_trapezoid(x+dy,
y,
2*dy,
margin,
0, # no hole
-rotation + math.pi)
elif self.shape == Atom.roundrect:
x, y = self.size.x, self.size.y
r = min(x, y) * self.roundrect_rratio
if margin > -r:
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
(x+2*margin, y+2*margin,
r+margin,
0, 0, # no hole
rotation), unit=MM)
return GenericMacros.rounded_rect(x+2*margin,
y+2*margin,
r+margin,
0, # no hole
rotation)
else:
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation)
@ -595,18 +558,27 @@ class Pad:
@sexp_type('model')
class Model:
name: str = ''
hide: Flag() = False
at: Named(XYZCoord) = field(default_factory=XYZCoord)
offset: Named(XYZCoord) = field(default_factory=XYZCoord)
opacity: Named(float) = None
scale: Named(XYZCoord) = field(default_factory=XYZCoord)
rotate: Named(XYZCoord) = field(default_factory=XYZCoord)
@sexp_type('component_classes')
class FootprintComponentClasses:
classes: List(Named(str, name='class')) = field(default_factory=list)
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('footprint')
class Footprint:
name: str = None
_version: Named(int, name='version') = 20221018
generator: Named(Atom) = Atom.gerbonara
uuid: UUID = field(default_factory=UUID)
generator: Named(str) = Atom.gerbonara
generator_version: Named(str) = __version__
locked: Flag() = False
placed: Flag() = False
layer: Named(str) = 'F.Cu'
@ -616,12 +588,14 @@ class Footprint:
descr: Named(str) = None
tags: Named(str) = None
properties: List(DrawnProperty) = field(default_factory=list)
component_classes: FootprintComponentClasses = None
path: Named(str) = None
sheetname: Named(str) = None
sheetfile: Named(str) = None
autoplace_cost90: Named(float) = None
autoplace_cost180: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin_ratio: Named(float) = None
solder_paste_margin: Named(float) = None
solder_paste_ratio: Named(float) = None
clearance: Named(float) = None
@ -639,15 +613,15 @@ class Footprint:
arcs: List(Arc) = field(default_factory=list)
polygons: List(Polygon) = field(default_factory=list)
curves: List(Curve) = field(default_factory=list)
dimensions: List(Dimension) = field(default_factory=list)
dimensions: List(gr.Dimension) = field(default_factory=list)
pads: List(Pad) = field(default_factory=list)
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
embedded_fonts: Named(YesNoAtom()) = False
models: List(Model) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None
_bounding_box: tuple = None
board: object = None
board: object = field(repr=False, default=None)
def __after_parse__(self, parent):
for pad in self.pads:
@ -694,6 +668,10 @@ class Footprint:
if not self.property_value('Description', None):
self.set_property('Description', self.descr or '', 0, 0, 0)
def reset_nets(self):
for pad in self.pads:
pad.reset_net()
@property
def pads_by_number(self):
return {(int(pad.number) if pad.number.isnumeric() else pad.number): pad for pad in self.pads if pad.number}
@ -720,6 +698,14 @@ class Footprint:
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
def copy_placement(self, template):
# Fix up rotation of pads - KiCad saves each pad's rotation in *absolute* coordinates, not relative to the
# footprint. Because we overwrite the footprint's rotation below, we have to first fix all pads to match the
# new rotation.
self.rotate(math.radians(template.at.rotation - self.at.rotation))
self.at = copy.copy(template.at)
self.side = template.side
@property
def version(self):
return self._version
@ -804,7 +790,7 @@ class Footprint:
self.layer = flip_layer(self.layer)
for obj in self.objects():
if hasattr(obj, 'layer'):
if getattr(obj, 'layer', None) is not None:
obj.layer = flip_layer(obj.layer)
if hasattr(obj, 'layers'):
@ -814,8 +800,9 @@ class Footprint:
obj.effects.justify.mirror = not obj.effects.justify.mirror
for obj in self.properties:
obj.effects.justify.mirror = not obj.effects.justify.mirror
obj.layer = flip_layer(obj.layer)
if obj.layer is not None:
obj.effects.justify.mirror = not obj.effects.justify.mirror
obj.layer = flip_layer(obj.layer)
@property
def single_sided(self):
@ -854,19 +841,20 @@ class Footprint:
around the given coordinates in the global coordinate space. Otherwise rotate around the footprint's origin. """
if (cx, cy) != (None, None):
x, y = self.at.x-cx, self.at.y-cy
self.at.x = math.cos(angle)*x - math.sin(angle)*y + cx
self.at.y = math.sin(angle)*x + math.cos(angle)*y + cy
self.at.x = math.cos(-angle)*x - math.sin(-angle)*y + cx
self.at.y = math.sin(-angle)*x + math.cos(-angle)*y + cy
self.at.rotation = (self.at.rotation - math.degrees(angle)) % 360
self.at.rotation = (self.at.rotation + math.degrees(angle)) % 360
for pad in self.pads:
pad.at.rotation = (pad.at.rotation - math.degrees(angle)) % 360
pad.at.rotation = (pad.at.rotation + math.degrees(angle)) % 360
for prop in self.properties:
prop.at.rotation = (prop.at.rotation - math.degrees(angle)) % 360
if prop.at is not None:
prop.at.rotation = (prop.at.rotation + math.degrees(angle)) % 360
for text in self.texts:
text.at.rotation = (text.at.rotation - math.degrees(angle)) % 360
text.at.rotation = (text.at.rotation + math.degrees(angle)) % 360
def set_rotation(self, angle):
old_deg = self.at.rotation
@ -877,7 +865,8 @@ class Footprint:
pad.at.rotation = (pad.at.rotation + delta) % 360
for prop in self.properties:
prop.at.rotation = (prop.at.rotation + delta) % 360
if prop.at is not None:
prop.at.rotation = (prop.at.rotation + delta) % 360
for text in self.texts:
text.at.rotation = (text.at.rotation + delta) % 360
@ -897,18 +886,21 @@ class Footprint:
(self.zones if zones else []),
self.groups if groups else [])
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
def render(self, layer_stack, layer_map=None, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
x += self.at.x
y += self.at.y
rotation += math.radians(self.at.rotation)
for obj in self.objects(pads=False, text=text, zones=False):
if layer_map is None:
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
for obj in self.objects(pads=False, text=text, zones=False, groups=False):
if not (layer := layer_map.get(obj.layer)):
continue
for fe in obj.render(variables=variables):
fe.rotate(rotation)
fe.offset(x, -y, MM)
fe.offset(x, y, MM)
layer_stack[layer].objects.append(fe)
for obj in self.pads:
@ -940,7 +932,7 @@ class Footprint:
for fe in obj.render(margin=margin, cache=cache):
fe.rotate(rotation)
fe.offset(x, -y, MM)
fe.offset(x, y, MM)
if isinstance(fe, go.Flash) and fe.aperture:
fe.aperture = fe.aperture.rotated(rotation)
layer_stack[layer_map[layer]].objects.append(fe)
@ -948,7 +940,7 @@ class Footprint:
for obj in self.pads:
for fe in obj.render_drill():
fe.rotate(rotation)
fe.offset(x, -y, MM)
fe.offset(x, y, MM)
if obj.type == Atom.np_thru_hole:
layer_stack.drill_npth.append(fe)
@ -956,10 +948,9 @@ class Footprint:
layer_stack.drill_pth.append(fe)
def bounding_box(self, unit=MM):
if not self._bounding_box:
if not hasattr(self, '_bounding_box'):
stack = LayerStack()
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
self.render(stack, layer_map=None, x=0, y=0, rotation=0, flip=False, text=False, variables={})
self._bounding_box = stack.bounding_box(unit)
return self._bounding_box
@ -984,9 +975,7 @@ class FootprintInstance(Positioned):
if self.value is not None:
variables['VALUE'] = str(self.value)
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
self.sexp.render(layer_stack, layer_map,
self.sexp.render(layer_stack, layer_map=None,
x=x, y=y, rotation=rotation,
flip=flip,
text=(not self.hide_text),

View file

@ -1,6 +1,8 @@
import string
import math
import base64
import textwrap
from .sexp import *
from .base_types import *
@ -9,7 +11,7 @@ from .primitives import *
from ... import graphic_objects as go
from ... import apertures as ap
from ...newstroke import Newstroke
from ...utils import rotate_point, MM
from ...utils import rotate_point, MM, arc_bounds
@sexp_type('layer')
class TextLayer:
@ -18,10 +20,12 @@ class TextLayer:
@sexp_type('gr_text')
class Text(TextMixin):
class Text(TextMixin, BBoxMixin):
locked: Flag() = False
text: str = ''
at: AtPos = field(default_factory=AtPos)
layer: TextLayer = field(default_factory=TextLayer)
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
render_cache: RenderCache = None
@ -31,16 +35,19 @@ class Text(TextMixin):
@sexp_type('gr_text_box')
class TextBox:
class TextBox(BBoxMixin):
locked: Flag() = False
text: str = ''
start: Named(XYCoord) = None
end: Named(XYCoord) = None
pts: PointList = field(default_factory=PointList)
margins: Margins = None
pts: PointList = field(default_factory=list)
angle: OmitDefault(Named(float)) = 0.0
layer: Named(str) = ""
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
border: Named(YesNoAtom()) = False
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
@ -53,7 +60,7 @@ class TextBox:
raise ValueError('Vector font text with empty render cache')
for poly in render_cache.polygons:
reg = go.Region([(p.x, -p.y) for p in poly.pts.xy], unit=MM)
reg = go.Region([(p.x, -p.y) for p in poly.pts], unit=MM)
if self.stroke:
if self.stroke.type not in (None, Atom.default, Atom.solid):
@ -69,13 +76,15 @@ class TextBox:
@sexp_type('gr_line')
class Line:
class Line(WidthMixin):
locked: Flag() = False
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
angle: Named(float) = None # wat
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def rotate(self, angle, cx=None, cy=None):
@ -98,6 +107,26 @@ class Line:
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
def bounding_box(self, unit=MM):
x_min, x_max = min(self.start.x, self.end.x), max(self.start.x, self.end.x)
y_min, y_max = min(self.start.y, self.end.y), max(self.start.y, self.end.y)
w = self.stroke.width if self.stroke else self.width
return (x_min-w, y_max-w), (x_max+w, y_max+w)
@sexp_type('target')
class Target(WidthMixin):
shape: AtomChoice(Atom.x, Atom.plus) = 'plus'
at: AtPos = field(default_factory=AtPos)
size: Rename(XYCoord) = field(default_factory=XYCoord)
width: Named(float) = None
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
raise NotImplementedError('Target objects are not implemented yet')
@sexp_type('fill')
class FillMode:
@ -105,7 +134,7 @@ class FillMode:
fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
@classmethod
def __map__(kls, obj, parent=None):
def __map__(kls, obj, parent=None, path=''):
return obj[1] in (Atom.solid, Atom.yes)
@classmethod
@ -113,13 +142,15 @@ class FillMode:
yield [Atom.fill, Atom.solid if value else Atom.none]
@sexp_type('gr_rect')
class Rectangle:
class Rectangle(BBoxMixin, WidthMixin):
locked: Flag() = False
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = False
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
@ -130,9 +161,9 @@ class Rectangle:
if self.fill:
yield rect
if self.width:
if (w := self.stroke.width if self.stroke else self.width):
# FIXME stroke support
yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
yield from rect.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
@property
def top_left(self):
@ -145,21 +176,24 @@ class Rectangle:
@sexp_type('gr_circle')
class Circle:
class Circle(BBoxMixin, WidthMixin):
locked: Flag() = False
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = False
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
r = math.dist((self.center.x, -self.center.y), (self.end.x, -self.end.y))
aperture = ap.CircleAperture(self.width or 0, unit=MM)
w = self.stroke.width if self.stroke else self.width
aperture = ap.CircleAperture(w or 0, unit=MM)
arc = go.Arc.from_circle(self.center.x, -self.center.y, r, aperture=aperture, unit=MM)
if self.width:
if w:
# FIXME stroke support
yield arc
@ -170,15 +204,22 @@ class Circle:
self.center = self.center.with_offset(x, y)
self.end = self.end.with_offset(x, y)
def rotate(self, angle, cx=0, cy=0):
self.center = self.center.with_rotation(angle, cx, cy)
self.end = self.end.with_rotation(angle, cx, cy)
@sexp_type('gr_arc')
class Arc:
class Arc(WidthMixin, BBoxMixin):
locked: Flag() = False
start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
angle: Named(float) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
_: SEXP_END = None
center: XYCoord = None
@ -192,35 +233,35 @@ class Arc:
self.mid = center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.center = None
def rotate(self, angle, cx=None, cy=None):
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
def render(self, variables=None):
# FIXME stroke support
if not self.width:
if not (w := self.stroke.width if self.stroke else self.width):
return
aperture = ap.CircleAperture(self.width, unit=MM)
aperture = ap.CircleAperture(w, unit=MM)
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
(cx, cy), _r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=False, unit=MM)
(cx, cy), _r, clockwise = kicad_mid_to_center_arc(self.mid, self.start, self.end)
yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=not clockwise, unit=MM)
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.mid = self.mid.with_offset(x, y)
self.end = self.end.with_offset(x, y)
def rotate(self, angle, cx=None, cy=None):
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
@sexp_type('gr_poly')
class Polygon:
class Polygon(BBoxMixin, WidthMixin):
pts: ArcPointList = field(default_factory=list)
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = True
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
@ -236,41 +277,50 @@ class Polygon:
else: # base_types.Arc
points.append((point_or_arc.start.x, -point_or_arc.start.y))
points.append((point_or_arc.end.x, -point_or_arc.end.y))
(cx, cy), _r = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
centers.append((False, (cx, -cy)))
(cx, cy), _r, clockwise = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
centers.append((not clockwise, (cx, -cy)))
reg = go.Region(points, centers, unit=MM)
reg.close()
w = self.stroke.width if self.stroke else self.width
# FIXME stroke support
if self.width and self.width >= 0.005 or self.stroke.width and self.stroke.width > 0.005:
yield from reg.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
if w and w >= 0.005:
yield from reg.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
if self.fill:
yield reg
def offset(self, x=0, y=0):
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
self.pts = [pt.with_offset(x, y) for pt in self.pts]
def rotate(self, angle, cx=0, cy=0):
self.pts = [pt.with_rotation(angle, cx, cy) for pt in self.pts]
@sexp_type('gr_curve')
class Curve:
pts: PointList = field(default_factory=PointList)
class Curve(BBoxMixin, WidthMixin):
locked: Flag() = False
pts: PointList = field(default_factory=list)
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
def offset(self, x=0, y=0):
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
self.pts =[pt.with_offset(x, y) for pt in self.pts]
@sexp_type('gr_bbox')
class AnnotationBBox:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
width: Named(float) = None
fill: FillMode = False
def render(self, variables=None):
return []
@ -289,6 +339,7 @@ class DimensionFormat:
precision: Named(int) = 7
override_value: Named(str) = None
suppress_zeros: Flag() = False
suppress_zeroes: Flag() = False
@sexp_type('style')
@ -296,19 +347,36 @@ class DimensionStyle:
thickness: Named(float) = 0.1
arrow_length: Named(float) = 1.27
text_position_mode: Named(int) = 0
arrow_direction: Named(AtomChoice(Atom.inward, Atom.outward)) = None
extension_height: Named(float) = None
text_frame: Named(float) = None
extension_offset: Named(float) = None
keep_text_aligned: Flag() = False
@sexp_type('data')
class Base64Blob:
@classmethod
def __map__(kls, obj, parent=None, path=''):
_data, *content = obj
for x in content[:10]:
print(str(x))
return base64.b64decode(''.join(map(str, content)))
@classmethod
def __sexp__(kls, value):
encoded = base64.b64encode(value).decode()
yield [Atom.data, *textwrap.wrap(encoded, 76)]
@sexp_type('image')
class Image:
at: AtPos = field(default_factory=AtPos)
scale: Named(float) = None
layer: Named(str) = None
locked: Flag() = False
uuid: UUID = field(default_factory=UUID)
data: str = ''
data: Base64Blob = ''
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
@ -316,12 +384,15 @@ class Image:
@sexp_type('dimension')
class Dimension:
value: float = None
locked: Flag() = False
dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned
layer: Named(str) = 'Dwgs.User'
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = field(default_factory=Timestamp)
pts: PointList = field(default_factory=PointList)
pts: PointList = field(default_factory=list)
height: Named(float) = None
width: Named(float) = None
orientation: Named(int) = None
leader_length: Named(float) = None
gr_text: Text = None
@ -332,5 +403,60 @@ class Dimension:
raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')
def offset(self, x=0, y=0):
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
self.pts = [pt.with_offset(x, y) for pt in self.pts]
@sexp_type('options')
class PadStackLayerOptions:
anchor: AtomChoice(Atom.rect, Atom.circle) = Atom.circle
@sexp_type('primitives')
class PadStackPrimitives:
vectors: Rename(Line, name='gr_vector') = field(default_factory=list)
lines: List(Line) = field(default_factory=list)
bboxes: List(AnnotationBBox) = field(default_factory=list)
arcs: List(Arc) = field(default_factory=list)
circles: List(Circle) = field(default_factory=list)
curves: List(Curve) = field(default_factory=list)
polygons:List(Polygon) = field(default_factory=list)
@sexp_type('layer')
class PadStackLayer:
layer: str = ''
shape: Named(AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom)) = Atom.circle
size: Rename(XYCoord) = field(default_factory=XYCoord)
rect_delta: Rename(XYCoord) = None
offset: Rename(XYCoord) = None
roundrect_rratio: Named(float) = None
chamfer_ratio: Named(float) = None
chamfer: Chamfer = None
primitives: PadStackPrimitives = None
options: PadStackLayerOptions = None
thermal_bridge_angle: Named(float) = None
thermal_gap: Named(float) = None
thermal_bridge_width: Named(float) = None
clearance: Named(float) = None
zone_connect: Named(int) = None
@sexp_type('padstack')
class PadStack:
mode: Named(AtomChoice('front_inner_back', 'custom')) = Atom.front_inner_back
layers: List(PadStackLayer) = field(default_factory=list)
@sexp_type('teardrops')
class TeardropSpec:
best_length_ratio: Named(float) = 1.0
max_length: Named(float) = 2.0
best_width_ratio: Named(float) = 1.0
max_width: Named(float) = 2.0
curve_points: Named(int) = 0
filter_ratio: Named(float) = 0.9
enabled: Named(YesNoAtom()) = True
allow_two_segments: Named(YesNoAtom()) = True
prefer_zone_connections: Named(YesNoAtom()) = True

View file

@ -59,13 +59,22 @@ def gn_layer_to_kicad(layer, flip=False):
@sexp_type('general')
class GeneralSection:
thickness: Named(float) = 1.60
legacy_teardrops: Named(YesNoAtom()) = False
drawings: Named(int) = None
tracks: Named(int) = None
zones: Named(int) = None
modules: Named(int) = None
nets: Named(int) = None
links: Named(int) = None
no_connects: Named(int) = None
area: Named(Array(float)) = None
@sexp_type('layers')
class LayerSettings:
index: int = 0
canonical_name: str = None
layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user) = Atom.signal
layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user, Atom.auxiliary) = Atom.signal
custom_name: str = None
@ -91,76 +100,29 @@ class StackupSettings:
castellated_pads: Named(YesNoAtom()) = None
edge_plating: Named(YesNoAtom()) = None
TFBool = YesNoAtom(yes=Atom.true, no=Atom.false)
@sexp_type('pcbplotparams')
class ExportSettings:
layerselection: Named(Atom) = None
plot_on_all_layers_selection: Named(Atom) = None
disableapertmacros: Named(TFBool) = False
usegerberextensions: Named(TFBool) = True
usegerberattributes: Named(TFBool) = True
usegerberadvancedattributes: Named(TFBool) = True
creategerberjobfile: Named(TFBool) = True
dashed_line_dash_ratio: Named(float) = 12.0
dashed_line_gap_ratio: Named(float) = 3.0
svguseinch: Named(TFBool) = False
svgprecision: Named(float) = 4
excludeedgelayer: Named(TFBool) = False
plotframeref: Named(TFBool) = False
viasonmask: Named(TFBool) = False
mode: Named(int) = 1
useauxorigin: Named(TFBool) = False
hpglpennumber: Named(int) = 1
hpglpenspeed: Named(int) = 20
hpglpendiameter: Named(float) = 15.0
pdf_front_fp_property_popups: Named(TFBool) = True
pdf_back_fp_property_popups: Named(TFBool) = True
dxfpolygonmode: Named(TFBool) = True
dxfimperialunits: Named(TFBool) = False
dxfusepcbnewfont: Named(TFBool) = True
psnegative: Named(TFBool) = False
psa4output: Named(TFBool) = False
plotreference: Named(TFBool) = True
plotvalue: Named(TFBool) = True
plotinvisibletext: Named(TFBool) = False
sketchpadsonfab: Named(TFBool) = False
subtractmaskfromsilk: Named(TFBool) = False
outputformat: Named(int) = 1
mirror: Named(TFBool) = False
drillshape: Named(int) = 0
scaleselection: Named(int) = 1
outputdirectory: Named(str) = "gerber"
@sexp_type('setup')
class BoardSetup:
stackup: OmitDefault(StackupSettings) = field(default_factory=StackupSettings)
pad_to_mask_clearance: Named(float) = None
solder_mask_min_width: Named(float) = None
pad_to_past_clearance: Named(float) = None
pad_to_paste_clearance_ratio: Named(float) = None
aux_axis_origin: Rename(XYCoord) = None
grid_origin: Rename(XYCoord) = None
export_settings: ExportSettings = field(default_factory=ExportSettings)
@classmethod
def __map__(kls, obj, parent=None, path=''):
return obj
@sexp_type('net')
class Net:
index: int = 0
name: str = ''
@classmethod
def __sexp__(kls, value):
yield value
@sexp_type('segment')
class TrackSegment:
class TrackSegment(BBoxMixin):
start: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
width: Named(float) = 0.5
layer: Named(str) = 'F.Cu'
locked: Flag() = False
layer: Named(str) = 'F.Cu'
extra_layers: Named(Array(str), name='layers') = field(default_factory=list)
solder_mask_margin: Named(float) = None
net: Named(int) = 0
tstamp: Timestamp = field(default_factory=Timestamp)
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
@classmethod
def from_footprint_line(kls, line, flip=False):
@ -171,6 +133,15 @@ class TrackSegment:
self.start = XYCoord(self.start)
self.end = XYCoord(self.end)
def __after_parse__(self, parent):
if self.extra_layers:
self.layer, *self.extra_layers = self.extra_layers
def __before_sexp__(self):
if self.extra_layers:
self.extra_layers.insert(0, self.layer)
self.layer = None
@property
def layer_mask(self):
return layer_mask([self.layer])
@ -195,7 +166,7 @@ class TrackSegment:
@sexp_type('arc')
class TrackArc:
class TrackArc(BBoxMixin):
start: Rename(XYCoord) = field(default_factory=XYCoord)
mid: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
@ -203,14 +174,15 @@ class TrackArc:
layer: Named(str) = 'F.Cu'
locked: Flag() = False
net: Named(int) = 0
tstamp: Timestamp = field(default_factory=Timestamp)
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
_: SEXP_END = None
center: XYCoord = None
def __post_init__(self):
self.start = XYCoord(self.start)
self.end = XYCoord(self.end)
self.mid = XYCoord(self.mid) if self.mid else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.mid = XYCoord(self.mid) if self.center is None else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.center = None
@property
@ -238,19 +210,31 @@ class TrackArc:
self.end = self.end.with_offset(x, y)
@sexp_type('tenting')
class Tenting:
front: Flag() = False
back: Flag() = False
none: Flag() = False
@sexp_type('via')
class Via:
class Via(BBoxMixin):
via_type: AtomChoice(Atom.blind, Atom.micro) = None
locked: Flag() = False
at: Rename(XYCoord) = field(default_factory=XYCoord)
size: Named(float) = 0.8
drill: Named(float) = 0.4
layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu'])
teardrops: gr.TeardropSpec = None
tenting: Tenting = None
padstack: gr.PadStack = None
remove_unused_layers: Flag() = False
keep_end_layers: Flag() = False
free: Wrap(Flag()) = False
free: Named(YesNoAtom()) = False
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
net: Named(int) = 0
tstamp: Timestamp = field(default_factory=Timestamp)
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
@classmethod
def from_pad(kls, pad):
@ -303,22 +287,70 @@ class Via:
self.at = self.at.with_offset(x, y)
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('net_class')
class LegacyNetclass:
name: str = ''
description: str = ''
clearance: Named(float) = None
trace_width: Named(float) = None
via_dia: Named(float) = None
via_drill: Named(float) = None
uvia_dia: Named(float) = None
uvia_drill: Named(float) = None
diff_pair_width: Named(float) = None
diff_pair_gap: Named(float) = None
nets: Rename(List(Named(str)), name='add_net') = field(default_factory=list)
@sexp_type('generated')
class GeneratedPatterns:
type: Named(Atom) = ''
name: Named(str) = ''
layer: Named(str) = ''
locked: Flag() = False
members: Named(Array(Atom), name='members') = field(default_factory=list)
_ : SEXP_END = None
params: dict = field(default_factory=dict)
def __catchall__(self, sexp_value, path=''):
key, value = sexp_value
self.params[key] = value
@classmethod
def __sexp__(kls, value):
return [kls.name_atom,
['type', value.type],
['name', value.name],
['layer', value.layer],
['locked', ('true' if value.locked else 'false')],
*[[k, v] for k, v in value.params.items()],
['members', *value.members]]
SUPPORTED_FILE_FORMAT_VERSIONS = [20200119, 20200512, 20210108, 20211014, 20220621, 20221018, 20230517, 20240706, 20240922, 20241229]
@sexp_type('kicad_pcb')
class Board:
_version: Named(int, name='version') = 20230517
generator: Named(Atom) = Atom.gerbonara
generator: Named(str) = Atom.gerbonara
generator_version: Named(str) = Atom.gerbonara
legacy_generator: Named(Array(str), name='host') = None
general: GeneralSection = None
page: PageSettings = None
paper: PageSettings = None
legacy_page: Rename(PageSettings, 'page') = None
title_block: TitleBlock = None
layers: Named(Array(Untagged(LayerSettings))) = field(default_factory=list)
setup: BoardSetup = field(default_factory=BoardSetup)
properties: List(Property) = field(default_factory=list)
nets: List(Net) = field(default_factory=list)
legacy_netclasses: List(LegacyNetclass) = field(default_factory=list)
footprints: List(Footprint) = field(default_factory=list)
legacy_footprints: Rename(List(Footprint), 'module') = field(default_factory=list)
# Graphical elements
texts: List(gr.Text) = field(default_factory=list)
text_boxes: List(gr.TextBox) = field(default_factory=list)
lines: List(gr.Line) = field(default_factory=list)
targets: List(gr.Target) = field(default_factory=list)
rectangles: List(gr.Rectangle) = field(default_factory=list)
circles: List(gr.Circle) = field(default_factory=list)
arcs: List(gr.Arc) = field(default_factory=list)
@ -333,10 +365,11 @@ class Board:
# Other stuff
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
generated_patterns: List(GeneratedPatterns) = field(default_factory=list)
embedded_fonts: Named(YesNoAtom()) = False
_ : SEXP_END = None
original_filename: str = None
_bounding_box: tuple = None
_trace_index: rtree.index.Index = None
_trace_index_map: dict = None
@ -375,15 +408,15 @@ class Board:
(47, 'F.CrtYd', 'user', 'F.Courtyard'),
(48, 'B.Fab', 'user', None),
(49, 'F.Fab', 'user', None),
(50, 'User.1', 'user', None),
(51, 'User.2', 'user', None),
(52, 'User.3', 'user', None),
(53, 'User.4', 'user', None),
(54, 'User.5', 'user', None),
(55, 'User.6', 'user', None),
(56, 'User.7', 'user', None),
(57, 'User.8', 'user', None),
(58, 'User.9', 'user', None)]]
(50, 'User.1', 'auxiliary', None),
(51, 'User.2', 'auxiliary', None),
(52, 'User.3', 'auxiliary', None),
(53, 'User.4', 'auxiliary', None),
(54, 'User.5', 'auxiliary', None),
(55, 'User.6', 'auxiliary', None),
(56, 'User.7', 'auxiliary', None),
(57, 'User.8', 'auxiliary', None),
(58, 'User.9', 'auxiliary', None)]]
def rebuild_trace_index(self):
@ -499,6 +532,8 @@ class Board:
fp.board = self
self.nets = {net.index: net.name for net in self.nets}
if self.legacy_page:
self.paper, self.legacy_page = self.legacy_page, None
def __before_sexp__(self):
@ -660,11 +695,11 @@ class Board:
for fp in self.footprints:
if name and not match_filter(name, fp.name):
continue
if value and not match_filter(value, fp.properties.get('value', '')):
if value and not match_filter(value, fp.value):
continue
if reference and not match_filter(reference, fp.properties.get('reference', '')):
if reference and not match_filter(reference, fp.reference):
continue
if net and not any(match_filter(net, pad.net.name) for pad in fp.pads):
if net and not any(pad.net and match_filter(net, pad.net.name) for pad in fp.pads):
continue
if sheetname and not match_filter(sheetname, fp.sheetname):
continue
@ -780,15 +815,6 @@ class Board:
fe.offset(x, -y, MM)
layer_stack.drill_pth.append(fe)
def bounding_box(self, unit=MM):
if not self._bounding_box:
stack = LayerStack()
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
self._bounding_box = stack.bounding_box(unit)
return self._bounding_box
@dataclass
class BoardInstance(cad_pr.Positioned):
sexp: Board = None

View file

@ -38,7 +38,7 @@ def layer_mask(layers):
case 'B.Cu':
mask |= 1<<31
case _:
if (m := re.match(f'In([0-9]+)\.Cu', layer)):
if (m := re.match(fr'In([0-9]+)\.Cu', layer)):
mask |= 1<<int(m.group(1))
return mask
@ -62,6 +62,8 @@ def center_arc_to_kicad_mid(center, start, end):
def kicad_mid_to_center_arc(mid, start, end):
""" Convert kicad's slightly insane midpoint notation to standrad center/p1/p2 notation.
returns a ((center_x, center_y), radius, clockwise) tuple in KiCad coordinates.
Returns the center and radius of the circle passing the given 3 points.
In case the 3 points form a line, raises a ValueError.
"""
@ -81,7 +83,7 @@ def kicad_mid_to_center_arc(mid, start, end):
cy = ((p1[0] - p2[0]) * cd - (p2[0] - p3[0]) * bc) / det
radius = math.sqrt((cx - p1[0])**2 + (cy - p1[1])**2)
return ((cx, cy), radius)
return (cx, cy), radius, det < 0
@sexp_type('hatch')
@ -92,7 +94,7 @@ class Hatch:
@sexp_type('connect_pads')
class PadConnection:
type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
type: AtomChoice(Atom.yes, Atom.thru_hole_only, Atom.full, Atom.no) = None
clearance: Named(float) = 0
@ -118,6 +120,7 @@ class ZoneFill:
thermal_gap: Named(float) = 0.508
thermal_bridge_width: Named(float) = 0.508
smoothing: ZoneSmoothing = None
radius: Named(float) = 0.125
island_removal_mode: Named(int) = None
island_area_min: Named(float) = None
hatch_thickness: Named(float) = None
@ -133,34 +136,54 @@ class ZoneFill:
class FillPolygon:
layer: Named(str) = ""
island: Wrap(Flag()) = False
pts: PointList = field(default_factory=PointList)
pts: ArcPointList = field(default_factory=list)
@sexp_type('fill_segments')
class FillSegment:
layer: Named(str) = ""
pts: PointList = field(default_factory=PointList)
pts: ArcPointList = field(default_factory=list)
@sexp_type('polygon')
class ZonePolygon:
pts: PointList = field(default_factory=PointList)
pts: ArcPointList = field(default_factory=list)
@sexp_type('placement')
class ZonePlacement:
enabled: Named(YesNoAtom()) = False
sheetname: Named(str) = ''
@sexp_type('teardrop')
class ZoneTeardropSpec:
type: Named(AtomChoice(Atom.padvia, Atom.track_end)) = Atom.padvia
@sexp_type('attr')
class ZoneAttr:
teardrop: ZoneTeardropSpec = None
@sexp_type('zone')
class Zone:
locked: Flag() = False
net: Named(int) = 0
net_name: Named(str) = ""
layer: Named(str) = None
layers: Named(Array(str)) = None
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
name: Named(str) = None
hatch: Hatch = None
priority: OmitDefault(Named(int)) = 0
attr: ZoneAttr = None
connect_pads: PadConnection = field(default_factory=PadConnection)
min_thickness: Named(float) = 0.254
filled_areas_thickness: Named(YesNoAtom()) = True
keepout: ZoneKeepout = None
placement: ZonePlacement = None
fill: ZoneFill = field(default_factory=ZoneFill)
polygon: ZonePolygon = field(default_factory=ZonePolygon)
fill_polygons: List(FillPolygon) = field(default_factory=list)
@ -177,10 +200,26 @@ class Zone:
self.fill_polygons = []
self.fill_segments = []
def rotate(self, angle, cx=None, cy=None):
self.unfill()
self.polygon.pts = [pt.with_rotation(angle, cx, cy) for pt in self.polygon.pts]
def offset(self, x=0, y=0):
self.unfill()
self.polygon.pts = [pt.with_offset(x, y) for pt in self.polygon.pts]
def bounding_box(self):
min_x = min(pt.x for pt in self.polygon.pts)
min_y = min(pt.y for pt in self.polygon.pts)
max_x = max(pt.x for pt in self.polygon.pts)
max_y = max(pt.y for pt in self.polygon.pts)
return (min_x, min_y), (max_x, max_y)
@sexp_type('polygon')
class RenderCachePolygon:
pts: PointList = field(default_factory=PointList)
pts: PointList = field(default_factory=list)
@sexp_type('render_cache')
@ -190,4 +229,39 @@ class RenderCache:
polygons: List(RenderCachePolygon) = field(default_factory=list)
@sexp_type('margins')
class Margins:
left: float = 0.0
top: float = 0.0
right: float = 0.0
bottom: float = 0.0
@sexp_type('comment')
class TitleComment:
@classmethod
def __map__(kls, obj, parent=None, path=''):
lines = []
for lineno, content in zip(obj[1::2], obj[2::2]):
while lineno > len(lines):
lines.append('')
lines[lineno-1] = content
@classmethod
def __sexp__(kls, value):
l = [Atom.comment]
for i, line in enumerate(value.splitlines(), start=1):
l.append(i)
l.append(line.rstrip('\n'))
return l
@sexp_type('title_block')
class TitleBlock:
title: Named(str) = ''
date: Named(str) = ''
rev: Named(str) = ''
company: Named(str) = ''
comment: TitleComment = None

View file

@ -19,6 +19,7 @@ from .symbols import Symbol
from . import graphical_primitives as gr
from .. import primitives as cad_pr
from ... import __version__
from ... import graphic_primitives as gp
from ... import graphic_objects as go
@ -84,6 +85,12 @@ class NoConnect:
fill='none', stroke_width='0.254', stroke=colorscheme.no_connect)
@sexp_type('bus_alias')
class BusAlias:
name: str = ''
members: Named(Array(str)) = field(default_factory=list)
@sexp_type('bus_entry')
class BusEntry:
at: AtPos = field(default_factory=AtPos)
@ -132,7 +139,7 @@ def _polyline_bounds(self):
@sexp_type('wire')
class Wire:
points: PointList = field(default_factory=PointList)
points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@ -145,7 +152,7 @@ class Wire:
@sexp_type('bus')
class Bus:
points: PointList = field(default_factory=PointList)
points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@ -158,8 +165,9 @@ class Bus:
@sexp_type('polyline')
class Polyline:
points: PointList = field(default_factory=PointList)
points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
uuid: UUID = field(default_factory=UUID)
def bounding_box(self, default=None):
@ -169,33 +177,23 @@ class Polyline:
yield _polyline_svg(self, colorscheme.lines)
@sexp_type('text')
class Text(TextMixin):
text: str = ''
exclude_from_sim: Named(YesNoAtom()) = True
at: AtPos = field(default_factory=AtPos)
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('circle')
class Circle:
center: Rename(XYCoord) = field(default_factory=XYCoord)
radius: Named(float) = 0.0
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
uuid: UUID = field(default_factory=UUID)
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.text)
@sexp_type('label')
class LocalLabel(TextMixin):
text: str = ''
at: AtPos = field(default_factory=AtPos)
fields_autoplaced: Wrap(Flag()) = False
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('rectangle')
class Rectangle:
start: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
uuid: UUID = field(default_factory=UUID)
@property
def _text_offset(self):
return (0, -2*self.line_width)
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.labels)
def label_shape_path_d(shape, w, h):
l, r = {
@ -220,16 +218,36 @@ def label_shape_path_d(shape, w, h):
return d + f'L {e+r:.3f} {0:.3f} L {e:.3f} {r:.3f} Z'
@sexp_type('global_label')
class GlobalLabel(TextMixin):
@dataclass
class TextLabel(TextMixin):
text: str = ''
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.dot, Atom.round, Atom.diamond, Atom.rectangle)) = Atom.passive
exclude_from_sim: Named(YesNoAtom()) = False
at: AtPos = field(default_factory=AtPos)
fields_autoplaced: Wrap(Flag()) = False
fields_autoplaced: Named(YesNoAtom()) = False
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
properties: List(Property) = field(default_factory=list)
properties: List(DrawnProperty) = field(default_factory=list)
@sexp_type('text')
class Text(TextLabel):
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.text)
@sexp_type('label')
class LocalLabel(TextLabel):
@property
def _text_offset(self):
return (0, -2*self.line_width)
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.labels)
@sexp_type('global_label')
class GlobalLabel(TextLabel):
def to_svg(self, colorscheme=Colorscheme.KiCad):
text = super(TextMixin, self).to_svg(colorscheme.labels),
text.attrs['transform'] = f'translate({self.size*0.6:.3f} 0)'
@ -240,14 +258,7 @@ class GlobalLabel(TextMixin):
@sexp_type('hierarchical_label')
class HierarchicalLabel(TextMixin):
text: str = ''
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
at: AtPos = field(default_factory=AtPos)
fields_autoplaced: Wrap(Flag()) = False
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
class HierarchicalLabel(TextLabel):
def to_svg(self, colorscheme=Colorscheme.KiCad):
text, = TextMixin.to_svg(self, colorscheme.labels),
text.attrs['transform'] = f'translate({self.size*1.2:.3f} 0)'
@ -256,10 +267,20 @@ class HierarchicalLabel(TextMixin):
yield Tag('g', children=[frame, text])
@sexp_type('netclass_flag')
class NetclassFlag(TextLabel):
length: Named(float) = 2.54
def to_svg(self, colorscheme=Colorscheme.KiCad):
# FIXME
yield from TextMixin.to_svg(self, colorscheme.text)
@sexp_type('pin')
class Pin:
name: str = '1'
uuid: UUID = field(default_factory=UUID)
alternate: Named(str) = None
# Suddenly, we're doing syntax like this is yaml or something.
@ -268,6 +289,8 @@ class SymbolCrosslinkSheet:
path: str = ''
reference: Named(str) = ''
unit: Named(int) = 1
value: OmitDefault(Named(str)) = None
footprint: OmitDefault(Named(str)) = None
@sexp_type('project')
@ -286,6 +309,7 @@ class MirrorFlags:
class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
hide: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
@ -335,6 +359,14 @@ class DrawnProperty(TextMixin):
yield from TextMixin.to_svg(self, colorscheme.values)
@sexp_type('default_instance')
class DefaultSymbolInstance:
reference: Named(str) = ''
unit: Named(int) = 1
value: Named(str) = ''
footprint: Named(str) = ''
@sexp_type('symbol')
class SymbolInstance:
name: str = None
@ -347,16 +379,17 @@ class SymbolInstance:
in_bom: Named(YesNoAtom()) = True
on_board: Named(YesNoAtom()) = True
dnp: Named(YesNoAtom()) = True
fields_autoplaced: Wrap(Flag()) = True
fields_autoplaced: Named(YesNoAtom()) = True
uuid: UUID = field(default_factory=UUID)
default_instance: DefaultSymbolInstance = None
properties: List(DrawnProperty) = field(default_factory=list)
# AFAICT this property is completely redundant.
pins: List(Pin) = field(default_factory=list)
# AFAICT this property, too, is completely redundant. It ultimately just lists paths and references of at most
# three other uses of the same symbol in this schematic.
instances: Named(List(SymbolCrosslinkProject)) = field(default_factory=list)
instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
_ : SEXP_END = None
schematic: object = None
schematic: object = field(repr=False, default=None)
def __after_parse__(self, parent):
self.schematic = parent
@ -484,7 +517,11 @@ class SubsheetFill:
class Subsheet:
at: Rename(XYCoord) = field(default_factory=XYCoord)
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54))
fields_autoplaced: Wrap(Flag()) = True
exclude_from_sim: Named(YesNoAtom()) = False
in_bom: Named(YesNoAtom()) = False
on_board: Named(YesNoAtom()) = False
dnp: Named(YesNoAtom()) = False
fields_autoplaced: Named(YesNoAtom()) = True
stroke: Stroke = field(default_factory=Stroke)
fill: SubsheetFill = field(default_factory=SubsheetFill)
uuid: UUID = field(default_factory=UUID)
@ -495,10 +532,10 @@ class Subsheet:
_ : SEXP_END = None
sheet_name: object = field(default_factory=lambda: DrawnProperty('Sheetname', ''))
file_name: object = field(default_factory=lambda: DrawnProperty('Sheetfile', ''))
schematic: object = None
schematic: object = field(repr=False, default=None)
def __after_parse__(self, parent):
self.sheet_name, self.file_name = self._properties
self.sheet_name, self.file_name, *_extra_params = self._properties
self.schematic = parent
def __before_sexp__(self):
@ -543,6 +580,28 @@ class Subsheet:
**self.stroke.svg_attrs(colorscheme.lines))
@sexp_type('rule_area')
class RuleArea:
polyline: Polyline = None
@sexp_type('text_box')
class TextBox(TextMixin):
text: str = None
exclude_from_sim: Named(YesNoAtom()) = False
at: AtPos = field(default_factory=AtPos)
size: Rename(XYCoord) = None
margins: Rename(gr.Margins) = None
effects: TextEffect = field(default_factory=TextEffect)
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
def render(self, variables={}, cache=None):
yield from gr.TextBox.render(self, variables=variables)
@sexp_type('lib_symbols')
class LocalLibrary:
symbols: List(Symbol) = field(default_factory=list)
@ -552,26 +611,39 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20230620]
@sexp_type('kicad_sch')
class Schematic:
_version: Named(int, name='version') = 20230620
generator: Named(Atom) = Atom.gerbonara
generator: Named(str) = 'gerbonara'
generator_version: Named(str) = __version__
legacy_generator: Named(Array(str), name='host') = None
uuid: UUID = field(default_factory=UUID)
page_settings: PageSettings = field(default_factory=PageSettings)
legacy_page: Named(Array(int), name='page') = None
legacy_paper: Named(str, name='paper') = None
title_block: TitleBlock = None
# The doc says this is expected, but eeschema barfs when it's there.
# path: SheetPath = field(default_factory=SheetPath)
lib_symbols: LocalLibrary = field(default_factory=list)
junctions: List(Junction) = field(default_factory=list)
no_connects: List(NoConnect) = field(default_factory=list)
rule_areas: List(RuleArea) = field(default_factory=list)
netclass_flags: List(NetclassFlag) = field(default_factory=list)
bus_aliases: List(BusAlias) = field(default_factory=list)
bus_entries: List(BusEntry) = field(default_factory=list)
wires: List(Wire) = field(default_factory=list)
buses: List(Bus) = field(default_factory=list)
images: List(gr.Image) = field(default_factory=list)
polylines: List(Polyline) = field(default_factory=list)
circles: List(Circle) = field(default_factory=list)
rectangles: List(Rectangle) = field(default_factory=list)
texts: List(Text) = field(default_factory=list)
text_boxes: List(TextBox) = field(default_factory=list)
local_labels: List(LocalLabel) = field(default_factory=list)
global_labels: List(GlobalLabel) = field(default_factory=list)
hierarchical_labels: List(HierarchicalLabel) = field(default_factory=list)
symbols: List(SymbolInstance) = field(default_factory=list)
subsheets: List(Subsheet) = field(default_factory=list)
sheet_instances: Named(List(SubsheetCrosslinkSheet)) = field(default_factory=list)
sheet_instances: Named(Array(SubsheetCrosslinkSheet)) = field(default_factory=list)
symbol_instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
embedded_fonts: Named(YesNoAtom()) = False
_ : SEXP_END = None
original_filename: str = None

View file

@ -64,7 +64,7 @@ term_regex = r"""(?mx)
(\))|
([+-]?\d+\.\d+(?=[\s\)]))|
(\-?\d+(?=[\s\)]))|
([^0-9"\s()][^"\s)]*)
([^"\s()][^"\s)]*)
)"""

View file

@ -28,6 +28,10 @@ class AtomChoice:
def __sexp__(self, value):
yield value
def __str__(self):
choices = '|'.join(map(str, self.choices))
return f'AtomChoice({choices})'
class Flag:
def __init__(self, atom=None, invert=None):
@ -48,6 +52,11 @@ class Flag:
def __sexp__(self, value):
if bool(value) == (not self.invert):
yield self.atom
def __str__(self):
if self.invert is not None:
return f'Flag({self.atom}/{self.invert})'
return f'Flag({self.atom})'
def sexp(t, v):
@ -76,7 +85,7 @@ class MappingError(TypeError):
super().__init__(msg)
self.t, self.sexp = t, sexp
def map_sexp(t, v, parent=None):
def map_sexp(t, v, parent=None, path=''):
try:
if t is not Atom and hasattr(t, '__map__'):
return t.__map__(v, parent=parent)
@ -93,7 +102,7 @@ def map_sexp(t, v, parent=None):
elif isinstance(t, list):
t, = t
return [map_sexp(t, elem, parent=parent) for elem in v]
return [map_sexp(t, elem, parent=parent, path=f'{path}/{t}') for elem in v]
else:
raise TypeError(f'Python type {t} has no defined s-expression deserialization')
@ -102,7 +111,7 @@ def map_sexp(t, v, parent=None):
raise e
except Exception as e:
raise MappingError(f'Error trying to map {textwrap.shorten(str(v), width=120)} into type {t}', t, v) from e
raise MappingError(f'Error at {path} trying to map {textwrap.shorten(str(v), width=60)} into type {t}', t, v) from e
class WrapperType:
@ -111,7 +120,8 @@ class WrapperType:
def __bind_field__(self, field):
self.field = field
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
if self.next_type is not Atom:
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
def __atoms__(self):
if hasattr(self, 'name_atom'):
@ -133,12 +143,12 @@ class Named(WrapperType):
if self.name_atom is None:
self.name_atom = Atom(field.name)
def __map__(self, obj, parent=None):
def __map__(self, obj, parent=None, path=''):
k, *obj = obj
if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice):
return map_sexp(self.next_type, [*obj], parent=parent)
return map_sexp(self.next_type, [*obj], parent=parent, path=f'{path}/{self.name_atom}')
else:
return map_sexp(self.next_type, obj, parent=parent)
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
def __sexp__(self, value):
value = sexp(self.next_type, value)
@ -150,6 +160,9 @@ class Named(WrapperType):
yield [self.name_atom, *value]
def __str__(self):
return f'Named={self.name_atom}({self.next_type})'
class Rename(WrapperType):
def __init__(self, next_type, name=None):
@ -162,8 +175,8 @@ class Rename(WrapperType):
if hasattr(self.next_type, '__bind_field__'):
self.next_type.__bind_field__(field)
def __map__(self, obj, parent=None):
return map_sexp(self.next_type, obj, parent=parent)
def __map__(self, obj, parent=None, path=''):
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
def __sexp__(self, value):
value, = sexp(self.next_type, value)
@ -173,6 +186,9 @@ class Rename(WrapperType):
key, *rest = value
yield [self.name_atom, *rest]
def __str__(self):
return f'Rename={self.name_atom}({self.next_type})'
class OmitDefault(WrapperType):
def __bind_field__(self, field):
@ -182,19 +198,42 @@ class OmitDefault(WrapperType):
else:
self.default = field.default
def __map__(self, obj, parent=None):
return map_sexp(self.next_type, obj, parent=parent)
def __map__(self, obj, parent=None, path=''):
return map_sexp(self.next_type, obj, parent=parent, path=path)
def __sexp__(self, value):
if value != self.default:
yield from sexp(self.next_type, value)
def __str__(self):
return f'OmitDefault({self.field})'
class YesNoAtom:
def __init__(self, yes=Atom.yes, no=Atom.no):
self.yes, self.no = yes, no
def __map__(self, value, parent=None):
if not value: # compatibility with legacy flag style
return False
value, = value
return value == self.yes
def __sexp__(self, value):
yield self.yes if value else self.no
class LegacyCompatibleFlag:
'''Variant of YesNoAtom that accepts both the `(flag <yes/no>)` variant and the bare `flag` variant for compatibility.'''
def __init__(self, yes=Atom.yes, no=Atom.no, value_when_empty=True):
self.yes, self.no = yes, no
self.value_when_empty = value_when_empty
def __map__(self, value, parent=None):
if value == []:
return self.value_when_empty
value, = value
return value == self.yes
@ -203,41 +242,50 @@ class YesNoAtom:
class Wrap(WrapperType):
def __map__(self, value, parent=None):
def __map__(self, value, parent=None, path=''):
value, = value
return map_sexp(self.next_type, value, parent=parent)
return map_sexp(self.next_type, value, parent=parent, path=path)
def __sexp__(self, value):
for inner in sexp(self.next_type, value):
yield [inner]
def __str__(self):
return f'Wrap({self.next_type})'
class Array(WrapperType):
def __map__(self, value, parent=None):
return [map_sexp(self.next_type, [elem], parent=parent) for elem in value]
def __map__(self, value, parent=None, path=''):
return [map_sexp(self.next_type, [elem], parent=parent, path=path) for elem in value]
def __sexp__(self, value):
for e in value:
yield from sexp(self.next_type, e)
def __str__(self):
return f'Array({self.next_type})'
class Untagged(WrapperType):
def __map__(self, value, parent=None):
def __map__(self, value, parent=None, path=''):
value, = value
return self.next_type.__map__([self.next_type.name_atom, *value], parent=parent)
return self.next_type.__map__([self.next_type.name_atom, *value], parent=parent, path=path)
def __sexp__(self, value):
for inner in sexp(self.next_type, value):
_tag, *rest = inner
yield rest
def __str__(self):
return f'Untagged({self.next_type})'
class List(WrapperType):
def __bind_field__(self, field):
self.attr = field.name
def __map__(self, value, parent):
def __map__(self, value, parent, path=''):
l = getattr(parent, self.attr, [])
mapped = map_sexp(self.next_type, value, parent=parent)
mapped = map_sexp(self.next_type, value, parent=parent, path=f'{path}/{self.attr}')
l.append(mapped)
setattr(parent, self.attr, l)
@ -245,6 +293,9 @@ class List(WrapperType):
for elem in value:
yield from sexp(self.next_type, elem)
def __str__(self):
return f'List@{self.attr}({self.next_type})'
class _SexpTemplate:
@staticmethod
@ -252,22 +303,32 @@ class _SexpTemplate:
return [kls.name_atom]
@staticmethod
def __map__(kls, value, *args, parent=None, **kwargs):
def __map__(kls, value, *args, parent=None, path='', **kwargs):
positional = iter(kls.positional)
inst = kls(*args, **kwargs)
for v in value[1:]: # skip key
if isinstance(v, Atom) and v in kls.keys:
name, etype = kls.keys[v]
mapped = map_sexp(etype, [v], parent=inst)
mapped = map_sexp(etype, [v], parent=inst, path=f'{path}/{kls.name_atom}')
if mapped is not None:
setattr(inst, name, mapped)
elif isinstance(v, list):
name, etype = kls.keys[v[0]]
mapped = map_sexp(etype, v, parent=inst)
if mapped is not None:
setattr(inst, name, mapped)
key = v[0]
if key in kls.keys:
name, etype = kls.keys[key]
mapped = map_sexp(etype, v, parent=inst, path=f'{path}/{kls.name_atom}')
if mapped is not None:
setattr(inst, name, mapped)
elif hasattr(inst, '__catchall__'):
inst.__catchall__(v, path=f'{path}/{kls.name_atom}')
else:
#print('class has keys:')
#print('\n'.join(map(str, kls.keys)))
raise TypeError(f'Unhandled keyed argument {v!r} while parsing {kls}')
else:
try:

View file

@ -18,14 +18,15 @@ from .sexp import *
from .sexp_mapper import *
from .base_types import *
from ...utils import rotate_point, Tag, arc_bounds
from ... import __version__
from ...newstroke import Newstroke
from .schematic_colors import *
from .primitives import center_arc_to_kicad_mid
from .primitives import kicad_mid_to_center_arc, Margins
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter,
Atom.no_connect)
Atom.no_connect, Atom.unconnected)
PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
@ -51,7 +52,7 @@ class Pin:
style: PIN_STYLE = Atom.line
at: AtPos = field(default_factory=AtPos)
length: Named(float) = 2.54
hide: Flag() = False
hide: OmitDefault(Named(YesNoAtom())) = False
name: Rename(StyledText) = field(default_factory=StyledText)
number: Rename(StyledText) = field(default_factory=StyledText)
alternates: List(AltFunction) = field(default_factory=list)
@ -250,16 +251,24 @@ class Circle:
**self.stroke.svg_attrs(colorscheme.lines))
@sexp_type('radius')
class ArcRadius:
at: AtPos = field(default_factory=AtPos)
length: Named(float) = 0.0
angles: Rename(XYCoord) = field(default_factory=XYCoord)
@sexp_type('arc')
class Arc:
start: Rename(XYCoord) = field(default_factory=XYCoord)
mid: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
radius: ArcRadius = None
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
def bounding_box(self, default=None):
(cx, cy), r = center_arc_to_kicad_mid(self.mid, self.start, self.end)
(cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
x1, y1 = self.start.x, self.start.y
x2, y2 = self.mid.x-x1, self.mid.y-x2
x3, y3 = (self.end.x - x1)/2, (self.end.y - y1)/2
@ -268,7 +277,7 @@ class Arc:
def to_svg(self, colorscheme=Colorscheme.KiCad):
(cx, cy), r = center_arc_to_kicad_mid(self.mid, self.start, self.end)
(cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
x1r = self.start.x - cx
y1r = self.start.y - cy
@ -395,10 +404,12 @@ class Rectangle:
@sexp_type('property')
class Property(TextMixin):
private: Flag() = False
name: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
show_name: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
# Alias value for text mixin
@ -416,13 +427,24 @@ class Property(TextMixin):
@sexp_type('pin_numbers')
class PinNumberSpec:
hide: Flag() = False
hide: Named(YesNoAtom()) = False
@sexp_type('pin_names')
class PinNameSpec:
offset: OmitDefault(Named(float)) = 0.508
hide: Flag() = False
hide: OmitDefault(Named(YesNoAtom())) = False
@sexp_type('text_box')
class TextBox:
text: str = ''
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
at: AtPos = field(default_factory=AtPos)
size: Rename(XYCoord) = field(default_factory=XYCoord)
margins: Margins = None
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('symbol')
@ -433,6 +455,7 @@ class Unit:
polylines: List(Polyline) = field(default_factory=list)
rectangles: List(Rectangle) = field(default_factory=list)
texts: List(Text) = field(default_factory=list)
text_boxes: List(TextBox) = field(default_factory=list)
pins: List(Pin) = field(default_factory=list)
unit_name: Named(str) = None
_ : SEXP_END = None
@ -481,10 +504,12 @@ class Symbol:
pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
exclude_from_sim: Named(YesNoAtom()) = False
in_bom: Named(YesNoAtom()) = True
on_board: Named(YesNoAtom()) = True
properties: List(Property) = field(default_factory=list)
units: List(Unit) = field(default_factory=list)
embedded_fonts: Named(YesNoAtom()) = False
_ : SEXP_END = None
library = None
name: str = None
@ -573,7 +598,8 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
@sexp_type('kicad_symbol_lib')
class Library:
_version: Named(int, name='version') = 20211014
generator: Named(Atom) = Atom.gerbonara
generator: Named(str) = Atom.gerbonara
generator_version: Named(str) = __version__
symbols: List(Symbol) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None

View file

@ -4,7 +4,7 @@ import math
import warnings
from copy import copy
from itertools import zip_longest, chain
from dataclasses import dataclass, field, KW_ONLY
from dataclasses import dataclass, field, replace, KW_ONLY
from collections import defaultdict
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag, offset_bounds
@ -14,6 +14,9 @@ from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAper
from ..newstroke import Newstroke
class UNDEFINED:
pass
def sgn(x):
return -1 if x < 0 else 1
@ -115,7 +118,7 @@ class Board:
def layer_stack(self, layer_stack=None):
if layer_stack is None:
layer_stack = LayerStack()
layer_stack = LayerStack(board_name='proto')
cache = {}
for obj in chain(self.objects):
@ -319,6 +322,7 @@ class Text(Positioned):
xs = [x for points in strokes for x, _y in points]
ys = [y for points in strokes for _x, y in points]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
h = self.font_size + self.stroke_width # (max_y - min_y)
if self.h_align == 'left':
x0 = 0
@ -329,16 +333,16 @@ class Text(Positioned):
else:
raise ValueError('h_align must be one of "left", "center", or "right".')
if self.v_align == 'top':
y0 = -(max_y - min_y)
if self.v_align == 'bottom':
y0 = h
elif self.v_align == 'middle':
y0 = -(max_y - min_y)/2
elif self.v_align == 'bottom':
y0 = h/2
elif self.v_align == 'top':
y0 = 0
else:
raise ValueError('v_align must be one of "top", "middle", or "bottom".')
if self.side == 'bottom':
if self.flip:
x0 += min_x + max_x
x_sign = -1
else:
@ -348,7 +352,7 @@ class Text(Positioned):
for stroke in strokes:
for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]):
obj = Line(x0+x_sign*x1, y0-y1, x0+x_sign*x2, y0-y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
obj = Line(x0+x_sign*x1, y0+y1, x0+x_sign*x2, y0+y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
obj.rotate(rotation)
obj.offset(obj_x, obj_y)
layer_stack['bottom' if flip else 'top', self.layer].objects.append(obj)
@ -365,11 +369,11 @@ class Text(Positioned):
x0 = -approx_w
if self.v_align == 'top':
y0 = -approx_h
y0 = 0
elif self.v_align == 'middle':
y0 = -approx_h/2
elif self.v_align == 'bottom':
y0 = 0
y0 = -approx_h
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
@ -382,6 +386,7 @@ class PadStackAperture:
offset_x: float = 0 # in PadStack units
offset_y: float = 0
rotation: float = 0
invert: bool = False
@dataclass(frozen=True, slots=True)
@ -396,20 +401,20 @@ class PadStack:
def flashes(self, x, y, rotation: float = 0, flip: bool = False):
for ap in self.apertures:
aperture = ap.aperture.rotated(ap.rotation + rotation)
fl = Flash(ap.offset_x, ap.offset_y)
fl = Flash(ap.offset_x, ap.offset_y, aperture, polarity_dark=not ap.invert, unit=self.unit)
fl.rotate(rotation)
fl.offset(x, y)
side = fl.side
side = ap.side
if flip:
side = {'top': 'bottom', 'bottom': 'top'}.get(side, side)
yield side, fl.layer, fl
yield side, ap.layer, fl
def render(self, layer_stack, x, y, rotation: float = 0, flip: bool = False):
for side, layer, flash in self.flashes(x, y, rotation, flip):
if side == 'drill' and use == 'plated':
if side == 'drill' and layer == 'plated':
layer_stack.drill_pth.objects.append(flash)
elif side == 'drill' and use == 'nonplated':
elif side == 'drill' and layer == 'nonplated':
layer_stack.drill_npth.objects.append(flash)
elif (side, layer) in layer_stack:
@ -449,17 +454,37 @@ class SMDStack(PadStack):
return kls(CircleAperture(dia, unit=unit), mask_expansion, paste_expansion, paste, flip, unit=unit)
@dataclass(frozen=True, slots=True)
class MechanicalHoleStack(PadStack):
drill_dia: float
mask_expansion: float = 0.0
mask_aperture = None
@property
def apertures(self):
mask_aperture = self.mask_aperture or CircleAperture(self.drill_dia + self.mask_expansion, unit=self.unit)
yield PadStackAperture(mask_aperture, 'top', 'mask')
yield PadStackAperture(mask_aperture, 'bottom', 'mask')
@property
def single_sided(self):
return False
@dataclass(frozen=True, slots=True)
class THTPad(PadStack):
drill_dia: float
pad_top: SMDStack
pad_bottom: SMDStack = None
aperture_inner: Aperture = None
aperture_inner: Aperture = UNDEFINED
plated: bool = True
def __post_init__(self):
if self.pad_bottom is None:
object.__setattr__(self, 'pad_bottom', replace(self.pad_top, flip=True))
if self.aperture_inner is UNDEFINED:
object.__setattr__(self, 'aperture_inner', self.pad_top.aperture)
if self.pad_top.flip:
raise ValueError('top pad cannot be flipped')
@ -472,7 +497,8 @@ class THTPad(PadStack):
def apertures(self):
yield from self.pad_top.apertures
yield from self.pad_bottom.apertures
yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
if self.aperture_inner is not None:
yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating)
@property
@ -486,7 +512,7 @@ class THTPad(PadStack):
@classmethod
def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDStack.circle(dia, rotation, mask_expansion, paste_expansion, paste, unit=unit)
pad = SMDStack.circle(dia, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@classmethod
@ -538,6 +564,10 @@ class Via(FrozenPositioned):
class Pad(Positioned):
pad_stack: PadStack
def render(self, layer_stack, cache=None):
x, y, rotation, flip = self.abs_pos
self.pad_stack.render(layer_stack, x, y, rotation, flip)
@property
def single_sided(self):
return self.pad_stack.single_sided

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ from quart import Quart, request, Response, send_file, abort
from . import protoboard as pb
from . import protoserve_data
from .primitives import SMDStack
from ..utils import MM, Inch
@ -25,7 +26,7 @@ def extract_importlib(package):
else:
assert item.is_dir()
item_out.mkdir()
stack.push((item, item_out))
stack.append((item, item_out))
return root
@ -62,10 +63,10 @@ def deserialize(obj, unit):
case 'smd':
match obj['pad_shape']:
case 'rect':
pad = pb.SMDPad.rect(0, 0, pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
stack = SMDStack.rect(pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
case 'circle':
pad = pb.SMDPad.circle(0, 0, min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
return pb.PatternProtoArea(pitch_x, pitch_y, obj=pad, unit=unit)
stack = SMDStack.circle(min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
return pb.PatternProtoArea(pitch_x, pitch_y, obj=stack, unit=unit)
case 'tht':
hole_dia = mil(float(obj['hole_dia']))
@ -79,11 +80,11 @@ def deserialize(obj, unit):
match obj['pad_shape']:
case 'rect':
pad = pb.THTPad.rect(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
pad = pb.THTPad.rect(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
case 'circle':
pad = pb.THTPad.circle(0, 0, hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit)
pad = pb.THTPad.circle(hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit)
case 'obround':
pad = pb.THTPad.obround(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
pad = pb.THTPad.obround(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
if oneside:
pad.pad_bottom = None
@ -106,7 +107,8 @@ def deserialize(obj, unit):
pitch = mil(float(obj.get('pitch', 2.54)))
hole_dia = mil(float(obj['hole_dia']))
pattern_dia = mil(float(obj['pattern_dia']))
return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit)
clearance = mil(float(obj['clearance']))
return pb.PatternProtoArea(pitch, pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, clearance, unit=unit), unit=unit)
case 'spiky':
return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit)
@ -127,6 +129,20 @@ def deserialize(obj, unit):
via_size=via_size
), margin=unit(1.5, MM), unit=unit)
case 'breadboard':
horizontal = obj.get('direction', 'v') == 'h'
drill = float(obj.get('hole_dia', 0.9))
return pb.BreadboardArea(clearance=clearance, drill=drill, horizontal=horizontal, unit=unit)
case 'starburst':
trace_width_x = float(obj.get('trace_width_x', 1.8))
trace_width_y = float(obj.get('trace_width_y', 1.8))
drill = float(obj.get('hole_dia', 0.9))
annular_ring = float(obj.get('annular', 1.2))
clearance = float(obj.get('clearance', 0.4))
mask_width = float(obj.get('mask_width', 0.5))
return pb.PatternProtoArea(pitch_x, pitch_y, pb.StarburstPad(pitch_x, pitch_y, trace_width_x, trace_width_y, clearance, mask_width, drill, annular_ring, unit=unit), unit=unit)
case 'rf':
pitch = float(obj.get('pitch', 2.54))
hole_dia = float(obj['hole_dia'])
@ -139,6 +155,7 @@ def to_board(obj):
w = float(obj.get('width', unit(100, MM)))
h = float(obj.get('height', unit(80, MM)))
corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM)))
margin = float(obj.get('margin', unit(2.0, MM)))
holes = obj.get('mounting_holes', {})
mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM)))
mounting_hole_offset = float(holes.get('offset', unit(5, MM)))
@ -155,13 +172,14 @@ def to_board(obj):
corner_radius=corner_radius,
mounting_hole_dia=mounting_hole_dia,
mounting_hole_offset=mounting_hole_offset,
margin=margin,
unit=unit)
@app.route('/preview.svg', methods=['POST'])
async def preview():
@app.route('/preview_<side>.svg', methods=['POST'])
async def preview(side):
obj = await request.get_json()
board = to_board(obj)
return Response(str(board.pretty_svg()), mimetype='image/svg+xml')
return Response(str(board.pretty_svg(side=side)), mimetype='image/svg+xml')
@app.route('/gerbers.zip', methods=['POST'])
async def gerbers():
@ -172,7 +190,10 @@ async def gerbers():
board.layer_stack().save_to_zipfile(f)
return Response(f.read_bytes(), mimetype='image/svg+xml')
if __name__ == '__main__':
def main():
app.run()
if __name__ == '__main__':
main()

View file

@ -177,11 +177,14 @@ input[type="text"]:focus:valid {
position: relative;
grid-area: main;
padding: 20px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: stretch;
}
#preview-image {
width: 100%;
height: 100%;
#preview > img {
flex-grow: 1;
object-fit: contain;
}
@ -280,6 +283,12 @@ input[type="text"]:focus:valid {
<span class="unit us">inch</span>
</label>
<label>Margin
<input type="text" placeholder="margin" name="margin" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<div class="group expand" data-group="round_corners">
<label>Round corners
<input name="enabled" type="checkbox" checked/>
@ -316,7 +325,8 @@ input[type="text"]:focus:valid {
</form>
</div>
<div id="preview">
<img id="preview-image" alt="Automatically generated preview image"/>
<img id="preview-image-top" alt="Automatically generated top side preview image"/>
<img id="preview-image-bottom" alt="Automatically generated bottom side preview image"/>
<div id="preview-message"></div>
</div>
<div id="links">
@ -401,6 +411,8 @@ input[type="text"]:focus:valid {
<a href="#" data-placeholder="rf" class="double-sided-only">RF THT area</a>
<a href="#" data-placeholder="spiky" class="double-sided-only">Spiky hybrid area</a>
<a href="#" data-placeholder="alio" class="double-sided-only">ALio hybrid area</a>
<a href="#" data-placeholder="starburst" class="double-sided-only">THT starburst area</a>
<a href="#" data-placeholder="breadboard" class="double-sided-only">Permanent breadboard area</a>
</div>
</div>
</template>
@ -468,7 +480,7 @@ input[type="text"]:focus:valid {
<span class="unit us">mil</span>
</label>
<label>Plating
<select name="plating" value="through">
<select name="plating" value="plated">
<option value="plated">Double-sided, through-plated</option>
<option value="nonplated">Double-sided, non-plated</option>
<option value="singleside">Single-sided, non-plated</option>
@ -494,6 +506,34 @@ input[type="text"]:focus:valid {
</div>
</template>
<template id="tpl-g-breadboard">
<div data-type="breadboard" class="group breadboard">
<h4>Permanent breadboard area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
</label>
<h5>Area Settings</h5>
<label>Direction
<select name="direction" value="v">
<option value="v">Vertical</option>
<option value="h">Horizontal</option>
</select>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Hole diameter
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-manhattan">
<div data-type="manhattan" class="group manhattan">
<h4>Manhattan area</h4>
@ -699,6 +739,58 @@ input[type="text"]:focus:valid {
</div>
</template>
<template id="tpl-g-starburst">
<div data-type="starburst" class="group starburst">
<h4>Starburst area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pitch Y
<input type="text" name="pitch_y" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Drill diameter
<input type="text" name="drill" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Annular ring
<input type="text" name="annular" placeholder="length" value="1.2" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pad clearance
<input type="text" name="clearance" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Soldermask wall
<input type="text" name="mask_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width X
<input type="text" name="trace_width_x" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width Y
<input type="text" name="trace_width_y" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<script>
document.querySelectorAll('.expand').forEach((elem) => {
const checkbox = elem.querySelector(':first-child > input');
@ -985,26 +1077,43 @@ input[type="text"]:focus:valid {
}
}
let previewBlobURL = null;
let previewTopBlobURL = null;
let previewBotBlobURL = null;
previewReloader = new RateLimiter(async () => {
if (document.querySelector('form').checkValidity()) {
document.querySelector('#preview-message').textContent = 'Reloading...';
document.querySelector('#preview-message').classList.add('loading');
const response = await fetch('preview.svg', {
const response_top = await fetch('preview_top.svg', {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: serialize(),
});
const data = await response.blob();
if (previewBlobURL) {
URL.revokeObjectURL(previewBlobURL);
const data_top = await response_top.blob();
if (previewTopBlobURL) {
URL.revokeObjectURL(previewTopBlobURL);
}
previewBlobURL = URL.createObjectURL(data);
document.querySelector('#preview-image').src = previewBlobURL;
previewTopBlobURL = URL.createObjectURL(data_top);
document.querySelector('#preview-image-top').src = previewTopBlobURL;
document.querySelector('#preview-message').textContent = '';
document.querySelector('#preview-message').classList.remove('loading');
const response_bot = await fetch('preview_bottom.svg', {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: serialize(),
});
const data_bot = await response_bot.blob();
if (previewBotBlobURL) {
URL.revokeObjectURL(previewBotBlobURL);
}
previewBotBlobURL = URL.createObjectURL(data_bot);
document.querySelector('#preview-image-bottom').src = previewBotBlobURL;
} else {
document.querySelector('#preview-message').classList.add('loading');
document.querySelector('#preview-message').textContent = 'Please correct any invalid fields.';

View file

@ -26,7 +26,7 @@ import shutil
from pathlib import Path
from functools import cached_property
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg, convex_hull
from . import graphic_primitives as gp
from . import graphic_objects as go
@ -60,6 +60,9 @@ class FileSettings:
#: If you want to export the macros with their original formulaic expressions (which is completely fine by the
#: Gerber standard, btw), set this parameter to ``False`` before exporting.
calculate_out_all_aperture_macros: bool = True
#: Internal field used to communicate if only decimal coordinates were found inside an Excellon file, or if it
#: contained at least some coordinates in fixed-width notation.
_file_has_fixed_width_coordinates: bool = False
# input validation
def __setattr__(self, name, value):
@ -348,6 +351,24 @@ class CamFile:
return sum_bounds(( p.bounding_box(unit) for p in self.objects ), default=default)
def convex_hull(self, tol=0.01, unit=None):
unit = unit or self.unit
points = []
for obj in self.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def to_excellon(self):
""" Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """
raise NotImplementedError()

View file

@ -23,7 +23,10 @@ import dataclasses
import re
import warnings
import json
import sys
import itertools
import webbrowser
import warnings
from pathlib import Path
from .utils import MM, Inch
@ -33,8 +36,21 @@ from . import layers as lyr
from . import __version__
from .cad.kicad import schematic as kc_schematic
from .cad.kicad import tmtheme
from .cad import protoserve
def _showwarning(message, category, filename, lineno, file=None, line=None):
if file is None:
file = sys.stderr
filename = Path(filename)
gerbonara_module_install_location = Path(__file__).parent.parent
if filename.is_relative_to(gerbonara_module_install_location):
filename = filename.relative_to(gerbonara_module_install_location)
print(f'{filename}:{lineno}: {message}', file=file)
warnings.showwarning = _showwarning
def _print_version(ctx, param, value):
if value and not ctx.resilient_parsing:
click.echo(f'Version {__version__}')
@ -130,6 +146,23 @@ def cli():
well as sets of those files """
pass
@cli.group('protoboard')
def protoboard_group():
pass
@protoboard_group.command()
@click.option('-h', '--host', default=None, help='Hostname to listen on. Defaults to localhost.')
@click.option('-p', '--port', type=int, default=1337, help='Port to listen on. Defaults to 1337')
def interactive(host, port):
''' Launch gerbonar's interactive protoboard designer in your browser '''
if host is None:
@protoserve.app.before_serving
async def open_browser():
webbrowser.open_new(f'http://localhost:{port}/')
protoserve.app.run(host=host, port=port, use_reloader=False, debug=False)
@cli.group('kicad')
def kicad_group():
@ -305,9 +338,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
scheme instead of keeping the old file names.''')
@click.argument('transform')
@click.argument('inpath')
@click.argument('outpath')
def transform(transform, units, output_format, inpath, outpath,
format_warnings, input_map, use_builtin_name_rules, output_naming_scheme):
@click.argument('outpath', type=click.Path(path_type=Path))
def transform(transform, units, output_format, inpath, outpath, format_warnings, input_map, use_builtin_name_rules,
output_naming_scheme, number_format, force_zip):
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
@ -322,16 +355,26 @@ def transform(transform, units, output_format, inpath, outpath,
with warnings.catch_warnings():
warnings.simplefilter(format_warnings)
if force_zip:
stack = lyr.LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules)
stack = lyr.LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
else:
stack = lyr.LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules)
stack = lyr.LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
_apply_transform(transform, units, stack)
output_format = None if output_format == 'reuse' else FileSettings.defaults()
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
gerber_settings=output_format,
excellon_settings=dataclasses.replace(output_format, zeros=None))
if number_format:
if output_format is None:
output_format = FileSettings.defaults()
a, _, b = number_format.partition('.')
output_format.number_format = (int(a), int(b))
if outpath.is_file() or outpath.suffix.lower() == '.zip':
stack.save_to_zipfile(outpath, naming_scheme=output_naming_scheme or {},
gerber_settings=output_format,
excellon_settings=dataclasses.replace(output_format, zeros=None))
else:
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
gerber_settings=output_format,
excellon_settings=dataclasses.replace(output_format, zeros=None))
@cli.command()
@ -412,7 +455,7 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
help='''Enable or disable file format warnings during parsing (default: on)''')
@click.option('--units', type=Unit(), help='Output bounding box in this unit (default: millimeter)')
@click.option('--units', type=Unit(), default='metric', help='Output bounding box in this unit (default: millimeter)')
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
@click.option('--input-units', type=Unit(), help='Override units of input file')
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')

View file

@ -30,9 +30,10 @@ from pathlib import Path
from .cam import CamFile, FileSettings
from .graphic_objects import Flash, Line, Arc
from .apertures import ExcellonTool
from .apertures import ExcellonTool, CircleAperture
from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
class ExcellonContext:
""" Internal helper class used for tracking graphics state when writing Excellon. """
@ -268,17 +269,19 @@ class ExcellonFile(CamFile):
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
return self
def to_gerber(self, errros='raise'):
def to_gerber(self, errors='raise'):
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
from .rs274x import GerberFile
out = GerberFile()
out.comments = self.comments
apertures = {}
for obj in self.objects:
if not (ap := apertures[obj.tool]):
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter)
if not (ap := apertures.get(obj.tool)):
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter, unit=obj.aperture.unit)
out.objects.append(dataclasses.replace(obj, aperture=ap))
return out
@property
def generator(self):
@ -325,7 +328,7 @@ class ExcellonFile(CamFile):
for fn in 'nc_param.txt', 'ncdrill.log':
if (param_file := filename.parent / fn).is_file():
settings = parse_allegro_ncparam(param_file.read_text())
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}')
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}', SyntaxWarning)
break
# Parse Zuken log file for settings
@ -333,7 +336,7 @@ class ExcellonFile(CamFile):
logfile = filename.with_suffix('.fdl')
if logfile.is_file():
settings = parse_zuken_logfile(logfile.read_text())
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}')
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}', SyntaxWarning)
if external_tools is None:
# Parse allegro log files for tools.
@ -376,7 +379,7 @@ class ExcellonFile(CamFile):
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
if mixed_plating:
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.', SyntaxWarning)
defined_tools = {}
tool_indices = {}
@ -566,6 +569,8 @@ class ExcellonParser(object):
self.filename = None
self.external_tools = external_tools or {}
self.found_kicad_format_comment = False
self.allegro_eof_toolchange_hack = False
self.allegro_eof_toolchange_hack_index = 1
def warn(self, msg):
warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
@ -606,18 +611,25 @@ 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(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]+')
@exprs.match(r';(?P<index1_prefix>T(?P<index1>[0-9]+))?\s+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.
self.generator_hints.append('allegro')
if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
index = int(match['index2'])
if match['index1'] and index != int(match['index1']): # index1 has leading zeros, index2 not.
raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!')
if index in self.tools:
self.warn('Re-definition of tool index {index}, overwriting old definition.')
if not match['index1_prefix']:
# This is a really nasty orcad file without tool change commands, that instead just puts all holes in order
# of the hole size definitions with M00's in between.
self.allegro_eof_toolchange_hack = True
# NOTE: We map "optionally" plated holes to plated holes for API simplicity. If you hit a case where that's a
# problem, please raise an issue on our issue tracker, explain why you need this and provide an example file.
is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL'))
@ -630,13 +642,19 @@ class ExcellonParser(object):
else:
unit = MM
if unit != self.settings.unit:
if self.settings.unit is None:
self.settings.unit = unit
elif unit != self.settings.unit:
self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the '
'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, '
'please raise an issue on our issue tracker.')
self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
if self.allegro_eof_toolchange_hack and self.active_tool is None:
self.active_tool = self.tools[index]
# Searching Github I found that EasyEDA has two different variants of the unit specification here.
@exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)')
def parse_easyeda_tooldef(self, match):
@ -753,6 +771,12 @@ class ExcellonParser(object):
def handle_end_of_program(self, match):
if self.program_state in (None, ProgramState.HEADER):
self.warn('M30 statement found before end of header.')
if self.allegro_eof_toolchange_hack:
self.allegro_eof_toolchange_hack_index = min(max(self.tools), self.allegro_eof_toolchange_hack_index + 1)
self.active_tool = self.tools[self.allegro_eof_toolchange_hack_index]
return
self.program_state = ProgramState.FINISHED
# TODO: maybe add warning if this is followed by other commands.
@ -762,14 +786,17 @@ class ExcellonParser(object):
def do_move(self, coord_groups):
x_s, x, y_s, y = coord_groups
if self.settings.number_format == (None, None) and '.' not in x:
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
if x != '00':
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
'it, because Allegro does not include this critical information in their Excellon output. If you '
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
'FileSettings object from excellon.parse_allegro_ncparam.')
if (x is not None and '.' not in x) or (y is not None and '.' not in y):
self.settings._file_has_fixed_width_coordinates = True
if self.settings.number_format == (None, None):
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
if x != '00':
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
'it, because Allegro does not include this critical information in their Excellon output. If you '
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
'FileSettings object from excellon.parse_allegro_ncparam.')
x = self.settings.parse_gerber_value(x)
if x_s:
@ -863,12 +890,17 @@ class ExcellonParser(object):
# from https://math.stackexchange.com/a/1781546
if a_s:
raise ValueError('Negative arc radius given')
r = settings.parse_gerber_value(a)
r = self.settings.parse_gerber_value(a)
x1, y1 = start
x2, y2 = end
dx, dy = (x2-x1)/2, (y2-y1)/2
x0, y0 = x1+dx, y1+dy
f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
d = math.hypot(dx, dy)
if d == 0:
raise ValueError('Arc radius notation requires distinct start and end points')
if r < d:
raise ValueError('Arc radius too small for endpoint distance')
f = math.sqrt(r**2 - d**2) / d
if clockwise:
cx = x0 + f*dy
cy = y0 - f*dx
@ -878,16 +910,16 @@ class ExcellonParser(object):
i, j = cx-start[0], cy-start[1]
else: # explicit center given
i = settings.parse_gerber_value(i)
i = self.settings.parse_gerber_value(i) or 0
if i_s:
i = -i
j = settings.parse_gerber_value(j)
j = self.settings.parse_gerber_value(j) or 0
if j_s:
j = -i
j = -j
self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit))
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?')
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?')
def parse_easyeda_format(self, match):
metric = match[1] in ('METRIC', 'M71')
@ -900,7 +932,10 @@ class ExcellonParser(object):
# This is used by newer autodesk eagles, fritzing and diptrace
if match[3]:
integer, _, fractional = match[3][1:].partition('.')
self.settings.number_format = len(integer), len(fractional)
if integer.strip('0') or fractional.strip('0'):
self.settings.number_format = int(integer), int(fractional)
else:
self.settings.number_format = len(integer), len(fractional)
elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment:
self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
@ -926,10 +961,10 @@ class ExcellonParser(object):
@exprs.match('(FMAT|VER),?([0-9]*)')
def handle_command_format(self, match):
if match[1] == 'FMAT':
# We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
# please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
# file.
if match[2] not in ('', '2'):
# We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the
# same coordinate and routing statements that we already support, so rejecting the header unconditionally
# needlessly breaks otherwise parseable files.
if match[2] not in ('', '1', '2'):
raise SyntaxError(f'Unsupported FMAT format version {match[2]}')
else: # VER
@ -958,6 +993,19 @@ class ExcellonParser(object):
else:
self.warn('Bare coordinate after end of file')
@exprs.match(xy_coord + 'G85' + xy_coord)
def handle_g85_slot(self, match):
if self.program_state == ProgramState.HEADER:
return
self.do_move(match.groups()[:4])
start, end = self.do_move(match.groups()[4:])
if not self.ensure_active_tool():
return
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
@exprs.match(r'DETECT,ON|ATC,ON|M06')
def parse_zuken_legacy_statements(self, match):
self.generator_hints.append('zuken')

View file

@ -307,6 +307,7 @@ class Region(GraphicObject):
def _offset(self, dx, dy):
self.outline = [ (x+dx, y+dy) for x, y in self.outline ]
self.arc_centers = [ (c[0], (c[1][0]+dx, c[1][1]+dy)) if c else None for c in self.arc_centers ]
def _rotate(self, angle, cx=0, cy=0):
self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
@ -322,7 +323,7 @@ class Region(GraphicObject):
def close(self):
if self.outline and self.outline[-1] != self.outline[0]:
self.outline.append(self.outline[-1])
self.outline.append(self.outline[0])
if self.arc_centers:
self.arc_centers.append((None, (None, None)))
@ -336,8 +337,9 @@ class Region(GraphicObject):
], unit=unit)
@classmethod
def from_arc_poly(kls, arc_poly, polarity_dark=True, unit=MM):
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity_dark, unit=unit)
def from_arc_poly(kls, arc_poly, polarity_dark=None, unit=MM):
polarity = arc_poly.polarity_dark if polarity_dark is None else polarity_dark
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity, unit=unit)
def append(self, obj):
if obj.unit != self.unit:

View file

@ -62,6 +62,12 @@ class GraphicPrimitive:
raise NotImplementedError()
def is_zero_size(self):
""" Return whether this primitive is zero size
:rtype: bool
"""
@dataclass(frozen=True)
class Circle(GraphicPrimitive):
@ -81,7 +87,11 @@ class Circle(GraphicPrimitive):
def to_arc_poly(self):
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
[(True, (self.x, self.y)), (True, (self.x, self.y))])
[(True, (self.x, self.y)), (True, (self.x, self.y))],
polarity_dark=self.polarity_dark)
def is_zero_size(self):
return math.isclose(self.r, 0)
@dataclass(frozen=True)
@ -120,14 +130,14 @@ class ArcPoly(GraphicPrimitive):
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
outline = []
for p1, p2, (clockwise, center) in self.segments():
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is None:
outline.append(p1)
outline.append((x1, y1))
else:
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
max_error=max_error, clip_max_error=clip_max_error))
outline.pop() # remove arc end point
return type(self)(outline)
return type(self)(outline, polarity_dark=self.polarity_dark)
def bounding_box(self):
bbox = (None, None), (None, None)
@ -179,6 +189,20 @@ class ArcPoly(GraphicPrimitive):
return self
def is_zero_size(self):
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is not None: # arc
if math.isclose(cx, x1) and math.isclose(cy, y1):
continue
if math.isclose(x1, x2) and math.isclose(y1, y2):
return False
if math.isclose(polygon_area(self.outline), 0):
return True
return False
@dataclass(frozen=True)
class Line(GraphicPrimitive):
""" Straight line with round end caps. """
@ -218,24 +242,33 @@ class Line(GraphicPrimitive):
color = fg if self.polarity_dark else bg
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
fill='none', stroke=color, stroke_width=str(width))
fill='none', stroke=color, stroke_width=str(width), stroke_linecap='round')
def to_arc_poly(self):
l = math.dist((self.x1, self.y1), (self.x2, self.y2))
if math.isclose(l, 0):
# degenerate case: a zero-length line becomes a circle.
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
polarity_dark=self.polarity_dark)
dx, dy = self.x2-self.x1, self.y2-self.y1
nx, ny = -dy/l, dx/l
rx, ry = nx*self.width/2, ny*self.width/2
return ArcPoly([
(self.x1+rx, self.y1+ry),
(self.x1-rx, self.y1-ry),
(self.x2-rx, self.y2-ry),
(self.x2+rx, self.y2+ry),
(self.x2-rx, self.y2-ry),
(self.x1-rx, self.y1-ry),
(self.x1+rx, self.y1+ry),
], [
(True, (self.x1, self.y1)),
None,
(True, (self.x2, self.y2)),
None,
])
(True, (self.x1, self.y1)),
None,
], polarity_dark=self.polarity_dark)
def is_zero_size(self):
return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2)
@dataclass(frozen=True)
@ -276,25 +309,35 @@ class Arc(GraphicPrimitive):
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}',
fill='none', stroke=color, stroke_width=width)
fill='none', stroke=color, stroke_width=width, stroke_linecap='round')
def to_arc_poly(self):
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
if math.isclose(r, 0):
# degenerate case: a zero-radius arc becomes a circle.
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
polarity_dark=self.polarity_dark)
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
return ArcPoly([
(self.x1+nx1, self.y1+nx1),
(self.x1-nx1, self.y1-nx1),
(self.x2-nx2, self.y2-nx2),
(self.x2+nx2, self.y2+nx2),
], [
(self.clockwise, (self.x1, self.y1)),
return ArcPoly([ # vertices
(self.x1+nx1, self.y1+ny1),
(self.x1-nx1, self.y1-ny1),
(self.x2-nx2, self.y2-ny2),
(self.x2+nx2, self.y2+ny2),
], [ # arc segments (direction, center)
(not self.clockwise, (self.x1, self.y1)),
(self.clockwise, (self.cx, self.cy)),
(self.clockwise, (self.x2, self.y2)),
(self.clockwise, (self.cx, self.cy)),
])
(not self.clockwise, (self.cx, self.cy)),
], polarity_dark=self.polarity_dark)
def is_zero_size(self):
return False # an arc with identical start and end points is defined as a circle
@dataclass(frozen=True)
@ -323,7 +366,7 @@ class Rectangle(GraphicPrimitive):
(x - (cw+sh), y + (ch+sw)),
(x + (cw+sh), y + (ch+sw)),
(x + (cw+sh), y - (ch+sw)),
])
], polarity_dark=self.polarity_dark)
def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg
@ -331,3 +374,6 @@ class Rectangle(GraphicPrimitive):
return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h),
**svg_rotation(self.rotation, self.x, self.y), fill=color)
def is_zero_size(self):
return math.isclose(self.w, 0) or math.isclose(self.h, 0)

View file

@ -82,6 +82,7 @@ MATCH_RULES = {
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
'header regex': [['sufficient', r'top .*|bottom .*', r'G04 DipTrace [.-0-9a-z]*\*']],
},
'target': {
@ -151,22 +152,25 @@ MATCH_RULES = {
'allegro': {
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
'drill mech': r'.*\.(drl|rou)',
'generic gerber': r'.*\.art',
'drill plated': r'.*\.(drl)',
'drill nonplated': r'.*\.(rou)',
'other unknown': r'.*(place|assembly|keep.?in|keep.?out).*\.art',
'autoguess': r'.*\.art',
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
'header regex': [['required,sufficient', r'.*\.art', r'G04 File Origin:\s+Cadence Allegro [0-9]+\.[0-9]+[-a-zA-Z0-9]*']],
},
'pads': {
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
'generic gerber': r'.*\.pho',
'drill mech': r'.*\.drl',
'autoguess': r'.*\.pho',
'drill plated': r'.*\.drl',
},
'zuken': {
'generic gerber': r'.*\.fph',
'autoguess': r'.*\.fph',
'gerber params': r'.*\.fpl',
'drill mech': r'.*\.fdr',
'drill unknown': r'.*\.fdr',
'excellon params': r'.*\.fdl',
'other netlist': r'.*\.ipc',
'ipc-2581': r'.*\.xml',

View file

@ -39,6 +39,7 @@ from .cam import FileSettings, LazyCamFile
from .layer_rules import MATCH_RULES
from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
from . import graphic_objects as go
from . import apertures as ap
from . import graphic_primitives as gp
@ -65,27 +66,27 @@ DEFAULT_COLORS = {
class NamingScheme:
kicad = {
'top copper': '{board_name}-F.Cu.gbr',
'top mask': '{board_name}-F.Mask.gbr',
'top silk': '{board_name}-F.SilkS.gbr',
'top paste': '{board_name}-F.Paste.gbr',
'bottom copper': '{board_name}-B.Cu.gbr',
'bottom mask': '{board_name}-B.Mask.gbr',
'bottom silk': '{board_name}-B.SilkS.gbr',
'bottom paste': '{board_name}-B.Paste.gbr',
'inner copper': '{board_name}-In{layer_number}.Cu.gbr',
'mechanical outline': '{board_name}-Edge.Cuts.gbr',
'top copper': '{board_name}-F_Cu.gbr',
'top mask': '{board_name}-F_Mask.gbr',
'top silk': '{board_name}-F_SilkS.gbr',
'top paste': '{board_name}-F_Paste.gbr',
'bottom copper': '{board_name}-B_Cu.gbr',
'bottom mask': '{board_name}-B_Mask.gbr',
'bottom silk': '{board_name}-B_SilkS.gbr',
'bottom paste': '{board_name}-B_Paste.gbr',
'inner copper': '{board_name}-In{layer_number}_Cu.gbr',
'mechanical outline': '{board_name}-Edge_Cuts.gbr',
'drill unknown': '{board_name}.drl',
'drill plated': '{board_name}-PTH.drl',
'drill nonplated': '{board_name}-NPTH.drl',
'other comments': '{board_name}-Cmts.User.gbr',
'other drawings': '{board_name}-Dwgs.User.gbr',
'top fabrication': '{board_name}-F.Fab.gbr',
'bottom fabrication': '{board_name}-B.Fab.gbr',
'top adhesive': '{board_name}-F.Adhes.gbr',
'bottom adhesive': '{board_name}-B.Adhes.gbr',
'top courtyard': '{board_name}-F.CrtYd.gbr',
'bottom courtyard': '{board_name}-B.CrtYd.gbr',
'other comments': '{board_name}-Cmts_User.gbr',
'other drawings': '{board_name}-Dwgs_User.gbr',
'top fabrication': '{board_name}-F_Fab.gbr',
'bottom fabrication': '{board_name}-B_Fab.gbr',
'top adhesive': '{board_name}-F_Adhes.gbr',
'bottom adhesive': '{board_name}-B_Adhes.gbr',
'top courtyard': '{board_name}-F_CrtYd.gbr',
'bottom courtyard': '{board_name}-B_CrtYd.gbr',
'other netlist': '{board_name}.d356',
}
@ -112,31 +113,61 @@ class NamingScheme:
}
def apply_rules(filenames, rules):
certain = False
gen = {}
already_matched = set()
header_regex = rules.pop('header regex', [])
header_regex_matched = [False] * len(header_regex)
def _match_files(filenames):
matches = {}
for generator, rules in MATCH_RULES.items():
already_matched = set()
gen = {}
matches[generator] = gen
for layer, regex in rules.items():
for fn in filenames:
if fn in already_matched:
continue
file_headers = {}
def get_header(path):
if path not in file_headers:
with open(path) as f:
file_headers[path] = f.read(16384)
return file_headers[path]
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
if layer == 'inner copper':
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
else:
target = layer
for layer, regex in rules.items():
for fn in filenames:
if fn in already_matched:
continue
gen[target] = gen.get(target, []) + [fn]
already_matched.add(fn)
return matches
target = None
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
if layer == 'inner copper':
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
else:
target = layer
gen[target] = gen.get(target, []) + [fn]
already_matched.add(fn)
for i, (match_type, layer_match, header_match) in enumerate(header_regex):
if re.fullmatch(layer_match, fn.name, re.IGNORECASE) or (
target is not None and re.fullmatch(layer_match, target, re.IGNORECASE)):
if re.search(header_match, get_header(fn)):
if 'sufficient' in match_type:
certain = True
header_regex_matched[i] = True
if any('required' in match_type and not match
for match, (match_type, *_) in zip(header_regex_matched, header_regex)):
return False, {}
return certain, gen
def _best_match(filenames):
matches = _match_files(filenames)
matches = {}
for generator, rules in MATCH_RULES.items():
certain, candidate = apply_rules(filenames, rules)
if certain:
return generator, candidate
matches[generator] = candidate
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
generator, files = matches[-1]
return generator, files
@ -243,7 +274,7 @@ def _layername_autoguesser(fn):
elif re.search('film', fn):
use = 'copper'
elif re.search('out(line)?', fn):
elif re.search('out(line)?|board.?geom(etry)?', fn):
use = 'outline'
side = 'mechanical'
@ -273,6 +304,9 @@ def _sort_layername(val):
assert side.startswith('inner_')
return int(side[len('inner_'):])
def convex_hull_to_lines(points, unit=MM):
for (x1, y1), (x2, y2) in zip(points, points[1:] + points):
yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(unit(0.1, MM), unit=unit), unit=unit)
class LayerStack:
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
@ -385,7 +419,7 @@ class LayerStack:
with ZipFile(file) as f:
f.extractall(path=tmp_indir)
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy)
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
inst.tmpdir = tmpdir
inst.original_path = Path(original_path or file)
inst.was_zipped = True
@ -421,6 +455,7 @@ class LayerStack:
given value.
:rtype: :py:class:`LayerStack`
"""
print_layermap = False
if autoguess:
generator, filemap = _best_match(files)
@ -445,14 +480,51 @@ class LayerStack:
filemap[layer].remove(fn)
filemap[layer] = filemap.get(layer, []) + [fn]
if 'autoguess' in filemap:
warnings.warn(f'This generator ({generator}) often exports ambiguous filenames. Falling back to autoguesser for some files. Use at your own peril. Autoguessed files: {", ".join(f.name for f in filemap["autoguess"])}')
print_layermap = True
autoguess_filenames = filemap.pop('autoguess')
matched = set()
for key, values in _do_autoguess(autoguess_filenames).items():
filemap[key] = filemap.get(key, []) + values
matched |= set(values)
if generator == 'allegro':
# Allegro gerbers often contain the inner layers with completely random filenames and no indication of
# layer ordering except for drawings in the mechanical files. We fall back to alphabetic ordering.
for fn in autoguess_filenames:
if fn not in matched:
with open(fn) as f:
header = f.read(16384)
if re.search(r'G04 Layer:\s*ETCH/.*\*', header):
filemap['unknown copper'] = filemap.get('unknown copper', []) + [fn]
if (unk := filemap.pop('unknown copper', None)):
unk = sorted(unk, key=str)
if 'top copper' not in filemap:
filemap['top copper'], *unk = [unk]
if 'bottom copper' not in filemap:
*unk, filemap['bottom copper'] = [unk]
i = 1
while unk and i < 128:
key = f'inner_{i:02d} copper'
if key not in filemap:
filemap[key] = [unk.pop(0)]
i += 1
if sum(len(files) for files in filemap.values()) < 6 and autoguess:
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
generator = None
print_layermap = True
filemap = _do_autoguess(files)
if len(filemap) < 6:
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
excellon_settings, external_tools = None, None
automatch_drill_scale = False
if generator == 'geda':
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the
# number format in files that use imperial units. Unfortunately it also doesn't include any hints that the
@ -470,16 +542,22 @@ class LayerStack:
if (external_tools := parse_allegro_logfile(file.read_text())):
break
del filemap['excellon params']
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
else:
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
# We'll run an automatic scale matching later.
excellon_settings = FileSettings(number_format=(2, 4))
automatch_drill_scale = True
print('remaining filemap')
import pprint
pprint.pprint(filemap)
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
if len(filemap) < 6:
raise SystemError('Cannot figure out gerber file mapping')
# FIXME use layer metadata from comments and ipc file if available
elif generator == 'zuken':
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
if len(filemap) < 6:
raise SystemError('Cannot figure out gerber file mapping')
# FIXME use layer metadata from comments and ipc file if available
@ -503,7 +581,12 @@ class LayerStack:
else:
excellon_settings = None
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})' for key, value in filemap.items() if len(value) > 1 and not 'drill' in key ]
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})'
for key, value in filemap.items()
if len(value) > 1 and\
not 'drill' in key and\
not 'excellon' in key and\
not key == 'other unknown']
if ambiguous:
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
@ -512,8 +595,11 @@ class LayerStack:
netlist = None
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
for key, paths in filemap.items():
if len(paths) > 1 and not 'drill' in key:
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
if len(paths) > 1 and\
not 'drill' in key and\
not 'excellon' in key and\
not key == 'other unknown':
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(map(str, value))}')
for path in paths:
id_result = identify_file(path.read_text())
@ -574,9 +660,72 @@ class LayerStack:
board_name = re.sub(r'^\W+', '', board_name)
board_name = re.sub(r'\W+$', '', board_name)
return kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
if automatch_drill_scale:
top_copper = layers[('top', 'copper')].to_excellon(errors='ignore', holes_only=True)
# precision is matching precision in mm
def map_coords(obj, precision=0.01, scale=1):
obj = obj.converted(MM)
return round(obj.x*scale/precision), round(obj.y*scale/precision)
aper_coords = {map_coords(obj) for obj in top_copper.drills()}
for drill_file in [drill_pth, drill_npth, *drill_layers]:
if not drill_file or not drill_pth.import_settings._file_has_fixed_width_coordinates:
continue
scale_matches = {}
for exp in range(-6, 6):
scale = 10**exp
hole_coords = {map_coords(obj, scale=scale) for obj in drill_file.drills()}
scale_matches[scale] = len(aper_coords - hole_coords), len(hole_coords - aper_coords)
scales_out = [(max(a, b), scale) for scale, (a, b) in scale_matches.items()]
_matches, scale = sorted(scales_out)[0]
warnings.warn(f'Performing automatic alignment of poorly exported drill layer. Scale matching results: {scale_matches}. Chosen scale: {scale}')
# Note: This is only used with allegro files, which use decimal points and explicit units in their tool
# definitions. Thus, we only scale object coordinates, and not apertures.
for obj in drill_file.objects:
obj.scale(scale)
stack = kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
if print_layermap:
warnings.warn('Auto-guessed layer map:\n' + stack.format_layer_map())
return stack
def format_layer_map(self):
lines = []
def print_layer(prefix, file):
nonlocal lines
if file is None:
lines.append(f'{prefix} <not found>')
else:
lines.append(f'{prefix} {file.original_path.name} {file}')
lines.append(' Drill files:')
print_layer(' Plated holes:', self.drill_pth)
print_layer(' Nonplated holes:', self.drill_npth)
for i, l in enumerate(self._drill_layers):
print_layer(f' Additional drill layer {i}:', l)
print_layer(' Board outline:', self.get('mechanical outline'))
lines.append(' Soldermask:')
print_layer(' Top:', self.get('top mask'))
print_layer(' Bottom:', self.get('bottom mask'))
lines.append(' Silkscreen:')
print_layer(' Top:', self.get('top silk'))
print_layer(' Bottom:', self.get('bottom silk'))
lines.append(' Copper:')
for (side, _use), layer in self.copper_layers:
print_layer(f' {side}:', layer)
return '\n'.join(lines)
def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
gerber_settings=None, excellon_settings=None):
""" Save this board into a zip file at the given path. For other options, see
@ -589,10 +738,7 @@ class LayerStack:
:param prefix: Store output files under the given prefix inside the zip file
"""
if path.is_file():
if overwrite_existing:
path.unlink()
else:
if path.is_file() and not overwrite_existing:
raise ValueError('output zip file already exists and overwrite_existing is False')
if gerber_settings and not excellon_settings:
@ -866,7 +1012,7 @@ class LayerStack:
if use == 'mask':
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white'))
layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})',
fill=default_fill, stroke=default_stroke, **stroke_attrs,
fill=default_fill, stroke=default_stroke, **stroke_attrs, fill_rule='evenodd',
**inkscape_attrs(f'{side} {use}'), transform=layer_transform))
for i, layer in enumerate(self.drill_layers):
@ -879,7 +1025,7 @@ class LayerStack:
id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'),
transform=layer_transform))
sc_y, tl_y = -1, (bounds[0][1] + bounds[1][1])
sc_y, tl_y = 1, 0
if side == 'bottom':
sc_x, tl_x = -1, (bounds[0][0] + bounds[1][0])
else:
@ -1114,22 +1260,6 @@ class LayerStack:
polys.append(' '.join(poly.path_d()) + ' Z')
return ' '.join(polys)
def outline_convex_hull(self, tol=0.01, unit=MM):
points = []
for obj in self.outline.instance.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def outline_polygons(self, tol=0.01, unit=MM):
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
@ -1144,8 +1274,17 @@ class LayerStack:
:param tol: :py:obj:`float` setting the tolerance below which two points are considered equal
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). SVG document unit. Default: mm
"""
if not self.outline:
warnings.warn("Board has no outline layer, or the outline layer could not be identified by file name. Using the copper layers' convex hull instead.")
points = sum((layer.instance.convex_hull(tol, unit) for (_side, _use), layer in self.copper_layers), start=[])
yield list(convex_hull_to_lines(convex_hull(points), unit))
return
maybe_allegro_hint = '' if self.generator != 'allegro' else ' This file looks like it was generated by Allegro/OrCAD. These tools produce quite mal-formed gerbers, and often export text on the outline layer. If you generated this file yourself, maybe try twiddling with the export settings.'
polygons = []
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
lines = [ prim for prim in lines if not prim.is_zero_size() ]
by_x = sorted([ (obj.x1, obj) for obj in lines ] + [ (obj.x2, obj) for obj in lines ], key=lambda x: x[0])
dist_sq = lambda x1, y1, x2, y2: (x2-x1)**2 + (y2-y1)**2
@ -1172,13 +1311,14 @@ class LayerStack:
j = 0 if d1 < d2 else 1
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
return self.outline_convex_hull(tol, unit)
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
return
if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
warnings.warn(f'three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
return self.outline_convex_hull(tol, unit)
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(cur, i)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
return
joins[(cur, i)] = (nearest, j)
joins[(nearest, j)] = (cur, i)

View file

@ -21,6 +21,7 @@
import re
import math
import copy
import warnings
from pathlib import Path
import dataclasses
@ -151,7 +152,7 @@ class GerberFile(CamFile):
self.map_apertures(lookup)
def to_excellon(self, plated=None, errors='raise'):
def to_excellon(self, plated=None, errors='raise', holes_only=False):
""" Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
@ -159,7 +160,10 @@ class GerberFile(CamFile):
new_objs = []
new_tools = {}
for obj in self.objects:
if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
if holes_only and not isinstance(obj, go.Flash):
continue
if not isinstance(obj, (go.Line, go.Arc, go.Flash)) or \
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
if errors == 'raise':
raise ValueError(f'Cannot convert {obj} to excellon.')
@ -371,8 +375,8 @@ class GerberFile(CamFile):
def invert_polarity(self):
""" Invert the polarity (color) of each object in this file. """
for obj in self.objects:
obj.polarity_dark = not p.polarity_dark
obj.polarity_dark = not obj.polarity_dark
class GraphicsState:
""" Internal class used to track Gerber processing state during import and export.
@ -459,7 +463,7 @@ class GraphicsState:
obj = go.Flash(*self.map_coord(*self.point), self.aperture,
polarity_dark=self._polarity_dark,
unit=self.unit,
attrs=self.object_attrs)
attrs=copy.copy(self.object_attrs))
return obj
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
@ -485,13 +489,13 @@ class GraphicsState:
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
else:
if i is None and j is None:
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
else:
if i is None:
@ -508,7 +512,7 @@ class GraphicsState:
if not multi_quadrant:
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
clockwise=clockwise, aperture=(self.aperture if aperture else None),
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
else:
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
@ -521,7 +525,7 @@ class GraphicsState:
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
clockwise=clockwise, aperture=aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
arcs = sorted(arcs, key=lambda a: a.numeric_error())
@ -595,6 +599,8 @@ class GerberParser:
NUMBER = r"[\+-]?\d+"
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
MAX_STEP_REPEAT_INSTANCES = 100000
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
STATEMENT_REGEXES = {
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
@ -623,6 +629,7 @@ class GerberParser:
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
'siemens_garbage': r'^ICAS$',
'step_repeat': fr'^SR(?P<coords>X(?P<X>[0-9]+)Y(?P<Y>[0-9]+)I(?P<I>{DECIMAL})J(?P<J>{DECIMAL}))?$',
'old_unit':r'(?P<mode>G7[01])',
'old_notation': r'(?P<mode>G9[01])',
'ignored': r"(?P<stmt>M01)",
@ -642,6 +649,8 @@ class GerberParser:
self.aperture_map = {}
self.aperture_macros = {}
self.current_region = None
self.step_repeat_coords = None
self.step_repeat_objects = None
self.eof_found = False
self.multi_quadrant_mode = None # used only for syntax checking
self.macros = {}
@ -784,7 +793,10 @@ class GerberParser:
# in multi-quadrant mode this may return None if start and end point of the arc are the same.
obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=self.multi_quadrant_mode)
if obj is not None:
self.target.objects.append(obj)
if self.step_repeat_objects:
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
else:
obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode)
if obj is not None:
@ -795,14 +807,21 @@ class GerberParser:
if self.current_region:
# Start a new region for every outline. As gerber has no concept of fill rules or winding numbers,
# it does not make a graphical difference, and it makes the implementation slightly easier.
self.target.objects.append(self.current_region)
if self.step_repeat_objects:
self.step_repeat_objects.append(self.current_region)
else:
self.target.objects.append(self.current_region)
self.current_region = go.Region(
polarity_dark=self.graphics_state.polarity_dark,
unit=self.file_settings.unit)
elif op == '3':
if self.current_region is None:
self.target.objects.append(self.graphics_state.flash(x, y))
obj = self.graphics_state.flash(x, y)
if self.step_repeat_objects:
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
else:
raise SyntaxError('DO3 flash statement inside region')
@ -843,6 +862,11 @@ class GerberParser:
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' )
# Polygon aperture rotation is specified in degrees, but radians are easier to work with
if match['shape'] == 'P':
if len(modifiers) > 2:
modifiers[2] = math.radians(modifiers[2])
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=tuple(self.aperture_attrs.items()),
original_number=number)
@ -1059,11 +1083,40 @@ class GerberParser:
else:
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']]
target[match['name']] = tuple(match['value'].split(','))
target[match['name']] = tuple(match['value'].split(',')) if match['value'] else ()
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
self.generator_hints.append('eagle')
def _parse_step_repeat(self, match):
if match['coords']:
if self.step_repeat_coords:
raise SyntaxError('SR step-repeat called inside ongoing SR step-repeat')
x, y = int(match['X']), int(match['Y'])
i, j = float(match['I']), float(match['J'])
if x < 1 or y < 1:
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
raise SyntaxError('SR step-repeat expands to too many instances')
self.step_repeat_coords = (x, y, i, j)
self.step_repeat_objects = []
else:
x, y, i, j = self.step_repeat_coords
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
raise SyntaxError('SR step-repeat expands to too many objects')
for obj in self.step_repeat_objects:
for nx in range(x):
for ny in range(y):
new_obj = copy.copy(obj)
new_obj.offset(i * nx, j * ny)
self.target.objects.append(new_obj)
self.step_repeat_coords = None
self.step_repeat_objects = None
def _parse_eof(self, match):
self.eof_found = True

View file

@ -605,6 +605,18 @@ def point_in_polygon(point, poly):
return res
def polygon_area(poly):
# https://en.wikipedia.org/wiki/Shoelace_formula
if not poly or len(poly) < 3:
return 0
acc = 0
for (x1, y1), (x2, y2) in zip(poly, poly[-1:] + poly):
acc += (y1 + y2) * (x1 - x2)
return acc/2
def bbox_intersect(a, b):
if a is None or b is None:
return False
@ -617,3 +629,16 @@ def bbox_intersect(a, b):
return x_overlap and y_overlap
def bbox_contains(outer, inner):
if outer is None or inner is None:
return False
(xa_min, ya_min), (xa_max, ya_max) = outer
(xb_min, yb_min), (xb_max, yb_max) = inner
contained_x = xa_min < xb_min and xb_max < xa_max
contained_y = ya_min < yb_min and yb_max < ya_max
return contained_x and contained_y

172
tests/conftest.py Normal file
View file

@ -0,0 +1,172 @@
import os
from pathlib import Path
import tqdm
import multiprocessing.pool
import subprocess
from itertools import chain
import pytest
from .image_support import ImageDifference, run_cargo_cmd, ImageSupport
@pytest.fixture
def kicad_container(request):
return request.config.kicad_container
@pytest.fixture()
def kicad_footprints_libdir(request):
return request.config.kicad_footprints_libdir
@pytest.fixture()
def kicad_symbols_libdir(request):
return request.config.kicad_symbols_libdir
@pytest.fixture()
def img_support(request):
return request.config.image_support
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
diff = left if isinstance(left, ImageDifference) else right
return [
f'Image difference assertion failed.',
f' Calculated difference: {diff}',
f' Histogram: {diff.histogram}', ]
# store report in node object so tmp_gbr can determine if the test failed.
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f'rep_{rep.when}', rep)
fail_dir = Path('gerbonara_test_failures')
def pytest_sessionstart(session):
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
return
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
f.unlink()
try:
run_cargo_cmd('resvg', '--help', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
def _update_repo_cache(lib_dir, repo_url, tag):
if not lib_dir.is_dir():
print(f'Checking out KiCad footprint repo tag {tag}')
subprocess.run(['git', '-c', 'advice.detachedHead=false', 'clone', '--branch', tag, '--depth', '1', repo_url, str(lib_dir)], check=True)
return True
else:
print(f'Found cached KiCad footprint checkout, updating to {tag}')
res = subprocess.run(['git', '-C', str(lib_dir), 'rev-parse', 'HEAD', f'{tag}^{{commit}}'], check=True, capture_output=True, text=True)
head_commit, tag_commit = res.stdout.strip().splitlines()
print('got commits', head_commit, tag_commit)
if head_commit != tag_commit:
subprocess.run(['git', '-C', str(lib_dir), 'fetch', '--depth', '1', 'origin', tag], check=True)
subprocess.run(['git', '-c', 'advice.detachedHead=false', '-C', str(lib_dir), 'reset', '--hard', tag], check=True)
subprocess.run(['git', '-C', str(lib_dir), 'clean', '--force', '-d', '-x'], check=True)
return True
else:
print('Up to date, only cleaning.')
subprocess.run(['git', '-C', str(lib_dir), 'clean', '--force', '-d', '-x'], check=True)
return False
def pytest_addoption(parser):
parser.addini('kicad_footprints_tag', 'git tag or branch for KiCad footprint library repo used as testdata', default='main')
parser.addini('kicad_symbols_tag', 'git tag or branch for KiCad symbol library repo used as testdata', default='main')
parser.addini('kicad_container_tag', 'docker hub tag for the KiCad container to use for exporting footprint images', default='main')
parser.addini('kicad_source_tag', 'git tag for the KiCad source repo whose demos directory is used as testdata', default='main')
parser.addoption("--use-cached-data", action="store_true", help="Do not re-check git repo caches and podman image")
def pytest_configure(config):
os.nice(20)
# Resvg can sometimes consume a lot of memory. Make sure we don't kill the user's session.
if (oom_adj := Path('/proc/self/oom_adj')).is_file():
oom_adj.write_text('15\n')
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
config.kicad_footprints_libdir = Path(lib_dir).expanduser()
else:
config.kicad_footprints_libdir = config.cache.mkdir('kicad-footprints') / 'repo'
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
config.kicad_symbols_libdir = Path(lib_dir).expanduser()
else:
config.kicad_symbols_libdir = config.cache.mkdir('kicad-symbols') / 'repo'
if (lib_dir := os.environ.get('KICAD_SOURCE')):
config.kicad_source_dir = Path(lib_dir).expanduser()
else:
config.kicad_source_dir = config.cache.mkdir('kicad-source') / 'repo'
did_updates = False
is_pytest_controller = 'PYTEST_XDIST_WORKER' not in os.environ
if is_pytest_controller and not config.getoption("--use-cached-data"):
# Update cached library repos unless they are overridden from outside.
if not os.environ.get('KICAD_FOOTPRINTS'):
tag = config.getini('kicad_footprints_tag')
did_updates |= _update_repo_cache(config.kicad_footprints_libdir, 'https://gitlab.com/kicad/libraries/kicad-footprints', tag)
if not os.environ.get('KICAD_SYMBOLS'):
tag = config.getini('kicad_symbols_tag')
did_updates |= _update_repo_cache(config.kicad_symbols_libdir, 'https://gitlab.com/kicad/libraries/kicad-symbols', tag)
if not os.environ.get('KICAD_SOURCE'):
tag = config.getini('kicad_source_tag')
did_updates |= _update_repo_cache(config.kicad_source_dir, 'https://gitlab.com/kicad/code/kicad', tag)
tag = config.getini("kicad_container_tag")
config.kicad_container = os.environ.get('KICAD_CONTAINER', f'registry.hub.docker.com/kicad/kicad:{tag}')
if is_pytest_controller and not config.getoption("--use-cached-data"):
print('Checking podman image')
res = subprocess.run(['podman', 'image', 'exists', config.kicad_container])
if res.returncode:
print('Updating podman image')
subprocess.run(['podman', 'pull', config.kicad_container], check=True)
did_updates = True
else:
print('Up to date.')
config.image_support = ImageSupport(config.cache.mkdir('image_cache'), config.kicad_container)
if is_pytest_controller and did_updates and not config.getoption("--use-cached-data"):
print('Checking KiCad footprint library render cache')
with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes
lib_dirs = list(config.kicad_footprints_libdir.glob('*.pretty'))
res = list(tqdm.tqdm(pool.imap(lambda path: config.image_support.bulk_populate_kicad_fp_export_cache(path), lib_dirs), total=len(lib_dirs)))
def pytest_generate_tests(metafunc):
if 'kicad_library_file' in metafunc.fixturenames:
library_files = list(metafunc.config.kicad_symbols_libdir.glob('*.kicad_sym'))
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
if 'kicad_mod_file' in metafunc.fixturenames:
mod_files = list(metafunc.config.kicad_footprints_libdir.glob('*.pretty/*.kicad_mod'))
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))
if 'kicad_sch_file' in metafunc.fixturenames:
files = list(metafunc.config.kicad_source_dir.glob('demos/*.kicad_sch'))
files += list(metafunc.config.kicad_source_dir.glob('qa/data/**/*.kicad_sch'))
metafunc.parametrize('kicad_sch_file', files, ids=list(map(str, files)))
if 'kicad_pcb_file' in metafunc.fixturenames:
files = list(metafunc.config.kicad_source_dir.glob('demos/*.kicad_pcb'))
files += list(metafunc.config.kicad_source_dir.glob('qa/data/**/*.kicad_pcb'))
metafunc.parametrize('kicad_pcb_file', files, ids=list(map(str, files)))

View file

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 556 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

373
tests/image_support.py Normal file
View file

@ -0,0 +1,373 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2022 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 subprocess
from pathlib import Path
import tempfile
import textwrap
import os
import sys
import stat
import random
import statistics
from functools import total_ordering
import shutil
import bs4
from contextlib import contextmanager
import hashlib
import tqdm
import numpy as np
from PIL import Image
@total_ordering
class ImageDifference:
def __init__(self, value, histogram):
self.value = value
self.histogram = histogram
def __float__(self):
return float(self.value)
def __eq__(self, other):
return float(self) == float(other)
def __lt__(self, other):
return float(self) < float(other)
def __str__(self):
return str(float(self))
@total_ordering
class Histogram:
def __init__(self, value, size):
self.value, self.size = value, size
def __eq__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value == other).all()
def __lt__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value <= other).all()
def __getitem__(self, index):
return self.value[index]
def __str__(self):
return f'{list(self.value)} size={self.size}'
def run_cargo_cmd(cmd, args, **kwargs):
if cmd.upper() in os.environ:
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
try:
return subprocess.run([cmd, *args], **kwargs)
except FileNotFoundError:
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
@contextmanager
def svg_soup(filename):
with open(filename, 'r') as f:
soup = bs4.BeautifulSoup(f.read(), 'xml')
yield soup
with open(filename, 'w') as f:
f.write(str(soup))
class ImageSupport:
def __init__(self, cachedir, kicad_container):
self.cache_root = cachedir
self.kicad_container = kicad_container
def cachedir(self, scope, filename, suffix):
scope = self.cache_root / scope
scope.mkdir(exist_ok=True)
return scope / f'{filename}.{suffix}'
def svg_to_png(self, in_svg, out_png, dpi=100, bg=None):
params = f'{dpi}{bg}'.encode()
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
cachefile = self.cachedir('svg_render', digest, 'png')
if not cachefile.is_file():
bg = 'black' if bg is None else bg
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
shutil.copy(cachefile, out_png)
def gerbv_export(self, in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
params = f'{origin}{size}{fg}{bg}'.encode()
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
cachefile = self.cachedir('gerbv-export', digest, 'svg')
if not cachefile.is_file():
print(f'Building cache for {Path(in_gbr).name}')
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
# and project file color settings.
# TODO: File issue upstream.
with tempfile.NamedTemporaryFile('w') as f:
if override_unit_spec:
units, zeros, digits = override_unit_spec
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
units = 0 if units == 'inch' else 1
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
(list 'autodetect 'Boolean 0)
(list 'zero_suppression 'Enum {zeros})
(list 'units 'Enum {units})
(list 'digits 'Integer {digits})
))''')
else:
unit_spec = ''
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
f.flush()
if override_unit_spec:
shutil.copy(f.name, '/tmp/foo.gbv')
x, y = origin
w, h = size
cmd = ['gerbv', '-x', export_format,
'--border=0',
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
f'--background={bg}',
f'--foreground={fg}',
'-o', str(cachefile), '-p', f.name]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
print(f'Re-using cache for {Path(in_gbr).name}')
shutil.copy(cachefile, out_svg)
def kicad_fp_export(self, mod_file, out_svg):
mod_file = Path(mod_file)
if mod_file.suffix.lower() != '.kicad_mod':
raise ValueError("KiCad footprint file must have .kicad_mod extension for kicad-cli to do it's thing")
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
cachefile = self.cachedir('kicad-footprint-export', digest, 'svg')
if not cachefile.is_file():
print(f'Building cache for {mod_file.name}')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
pretty_dir = mod_file.parent
fp_name = mod_file.name[:-len('.kicad_mod')]
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
self.kicad_container,
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}']
subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
out_file = Path(tmpdir) / f'{fp_name}.svg'
shutil.copy(out_file, cachefile)
else:
print(f'Re-using cache for {mod_file.name}')
shutil.copy(cachefile, out_svg)
def bulk_populate_kicad_fp_export_cache(self, pretty_dir):
def cachefile(mod_file):
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
return self.cachedir('kicad-footprint-export', digest, 'svg')
mod_files = list(pretty_dir.glob('*.kicad_mod'))
hit_rate = statistics.mean([int(cachefile(fn).is_file())
for fn in random.sample(mod_files, min(len(mod_files), 50))])
if hit_rate < 0.9:
#tqdm.tqdm.write(f'Modfile cache is out of date (hit rate {hit_rate*100:.0f}%), re-building entire cache in bulk')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
self.kicad_container,
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', f'/{pretty_dir.name}']
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
print('Error running command with command line:', ' '.join(e.cmd), file=sys.stderr)
raise e
for fn in mod_files:
out_file = Path(tmpdir) / fn.with_suffix('.svg').name
if not out_file.is_file():
tqdm.tqdm.write(f'Output file {out_file} is missing while bulk re-building cache for {pretty_dir}.')
else:
shutil.copy(out_file, cachefile(fn))
def cleanup_gerbv_svg(self, soup):
width = soup.svg["width"]
height = soup.svg["height"]
width = width[:-2] if width.endswith('pt') else width
height = height[:-2] if height.endswith('pt') else height
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
for group in soup.find_all('g'):
# gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit
# handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it
# seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders
# aperture macros into a new surface, which for some reason gets clipped by Cairo to the given
# 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']
def gerber_difference(self, reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
self.gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
self.gerbv_export(actual, act_svg.name, size=size, export_format='svg')
with svg_soup(ref_svg.name) as soup:
if svg_transform is not None:
svg = soup.svg
children = list(svg.children)
g = soup.new_tag('g', attrs={'transform': svg_transform})
for c in children:
g.append(c.extract())
svg.append(g)
self.cleanup_gerbv_svg(soup)
with svg_soup(act_svg.name) as soup:
self.cleanup_gerbv_svg(soup)
return self.svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
def gerber_difference_merge(self, ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
self.gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
self.gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
self.gerbv_export(actual, act_svg.name, size=size, export_format='svg')
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
print(f'=== {var} ===')
print(Path(locals()[var].name).read_text().splitlines()[1])
with svg_soup(ref1_svg.name) as soup1:
if svg_transform1 is not None:
svg = soup1.svg
children = list(svg.children)
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
for c in children:
g.append(c.extract())
svg.append(g)
self.cleanup_gerbv_svg(soup1)
with svg_soup(ref2_svg.name) as soup2:
if svg_transform2 is not None:
svg = soup2.svg
children = list(svg.children)
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
for c in children:
g.append(c.extract())
svg.append(g)
self.cleanup_gerbv_svg(soup2)
defs1 = soup1.find('defs')
if not defs1:
defs1 = soup1.new_tag('defs')
soup1.find('svg').insert(0, defs1)
defs2 = soup2.find('defs')
if defs2:
defs2 = defs2.extract()
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
# iterating because we modify the tree in the loop body.
for c in list(defs2.contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
defs1.append(c)
for use in soup2.find_all('use', recursive=True):
if (href := use.get('xlink:href', '')).startswith('#'):
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
svg1 = soup1.find('svg')
for c in list(soup2.find('svg').contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
svg1.append(c)
if composite_out:
shutil.copyfile(ref1_svg.name, composite_out)
with svg_soup(act_svg.name) as soup:
self.cleanup_gerbv_svg(soup)
return self.svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
def svg_difference(self, reference, actual, diff_out=None, background=None, dpi=100):
with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\
tempfile.NamedTemporaryFile(suffix='-act.png') as act_png:
self.svg_to_png(reference, ref_png.name, bg=background, dpi=dpi)
self.svg_to_png(actual, act_png.name, bg=background, dpi=dpi)
return self.image_difference(ref_png.name, act_png.name, diff_out=diff_out)
def image_difference(self, reference, actual, diff_out=None):
ref = np.array(Image.open(reference)).astype(float)
out = np.array(Image.open(actual)).astype(float)
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
# TODO blur images here before comparison to mitigate aliasing issue
delta = np.abs(out - ref).astype(float) / 255
if diff_out:
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
return (ImageDifference(delta.mean(), hist),
ImageDifference(delta.max(), hist),
Histogram(hist, out.size))

Some files were not shown because too many files have changed in this diff Show more