diff --git a/.gitignore b/.gitignore index 7170c77..f5927cd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,3 @@ gerbonara_test_failures __pycache__ .tox docs/_build/ -build -dist diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 47d8710..7a35f14 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,54 +14,69 @@ build:archlinux: GIT_SUBMODULE_STRATEGY: none script: - git config --global --add safe.directory "$CI_PROJECT_DIR" - - uv build + - pip3 install --user wheel setuptools + - python3 setup.py sdist bdist_wheel artifacts: name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" paths: - dist/* -# FIXME: disable tests since (a) currenty kicad-cli is broken (aborts on start), and the workaround of using an older -# version from the KiCad project's kicad-cli containers does not work in gitlab CI. Pain. -#test:archlinux: -# stage: test -# image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest" -# script: -# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols -# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints -# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*' -# dependencies: -# - build:archlinux -# cache: -# key: test-image-cache -# paths: -# - gerbonara/tests/image_cache/*.svg -# - gerbonara/tests/image_cache/*.png -# artifacts: -# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" -# when: on_failure -# paths: -# - gerbonara_test_failures/* -# -#test:ubuntu-rolling: -# stage: test -# image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:rolling" -# script: -# - python3 -m pip install --break-system-packages pytest beautifulsoup4 pillow numpy slugify lxml click scipy -# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols -# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints -# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*' -# dependencies: -# - build:archlinux -# cache: -# key: test-image-cache -# paths: -# - gerbonara/tests/image_cache/*.svg -# - gerbonara/tests/image_cache/*.png -# artifacts: -# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" -# when: on_failure -# paths: -# - gerbonara_test_failures/* +test:archlinux: + stage: test + image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest" + script: + - pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*' + dependencies: + - build:archlinux + cache: + key: test-image-cache + paths: + - gerbonara/tests/image_cache/*.svg + - gerbonara/tests/image_cache/*.png + artifacts: + name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" + when: on_failure + paths: + - gerbonara_test_failures/* + +test:ubuntu2204: + stage: test + image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:22.04" + script: + - python3 -m pip install pytest beautifulsoup4 pillow numpy slugify lxml click scipy + - python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*' + dependencies: + - build:archlinux + cache: + key: test-image-cache + paths: + - gerbonara/tests/image_cache/*.svg + - gerbonara/tests/image_cache/*.png + artifacts: + name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" + when: on_failure + paths: + - gerbonara_test_failures/* + +test:ubuntu2004: + stage: test + image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:20.04" + script: + - python3 -m pip install pytest beautifulsoup4 pillow numpy slugify lxml click scipy + - python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*' + dependencies: + - build:archlinux + cache: + key: test-image-cache + paths: + - gerbonara/tests/image_cache/*.svg + - gerbonara/tests/image_cache/*.png + artifacts: + name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" + when: on_failure + paths: + - gerbonara_test_failures/* + docs:archlinux: stage: test @@ -84,7 +99,7 @@ publish:gerbonara: cache: {} script: - export TWINE_USERNAME TWINE_PASSWORD - - pip3 install --user --break-system-packages twine rich + - pip3 install --user twine rich - twine upload dist/* dependencies: - build:archlinux diff --git a/docs/cli.rst b/docs/cli.rst index 02c8a01..8eb1ff3 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -60,7 +60,6 @@ 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] diff --git a/docs/ex-mask-islands.png b/docs/ex-mask-islands.png deleted file mode 100644 index b600738..0000000 Binary files a/docs/ex-mask-islands.png and /dev/null differ diff --git a/docs/examples.rst b/docs/examples.rst deleted file mode 100644 index a795a73..0000000 --- a/docs/examples.rst +++ /dev/null @@ -1,64 +0,0 @@ -.. _examples-doc: - -Examples -======== - -Solder mask rings ------------------ - -This example script takes a board exported with a more recent KiCad version, and removes solder mask everywhere, but -leaves a thin ring of solder mask around every pad. Might be useful for some artsy boards. - -.. image:: ex-mask-islands.png - -.. code-block:: python - from gerbonara import * - from shapely import * - - stack = layers.LayerStack.open('gerber') - # Let's work in mm here. Gerbonara will take care to convert units when the file is in US customary units. - (x1, y1), (x2, y2) = stack.bounding_box(unit=utils.MM) - - for l in [stack['bottom mask'], stack['top mask']]: - - # The solder mask gerber layer by convention is "negative". That is, a "dark" polarity (drawn) Gerber primitive - # will result in an opening in the solder mask. Conversely, an empty gerber file would lead to the entire board - # being covered in solder mask. - # - # Here, we add a rectangle covering the entire board so the entire board is *free* of solder mask. - - new = [graphic_objects.Region( - [(x1, y1), (x1, y2), (x2, y2), (x2, y1), (x1, y1)], - unit=utils.MM, - polarity_dark=True)] - - # Iterate through all objects on the solder mask layer. In later KiCad versions, everything on the solder mask - # layer is exported as a Gerber region, which is a really bad idea, but makes things easy for us here. - for obj in l.objects: - if isinstance(obj, gerbonara.graphic_objects.Region): - regions = [] - else: - regions = [gerbonara.graphic_objects.Region.from_arc_poly(prim.to_arc_poly()) - for prim in obj.to_primitives(unit=gerbonara.utils.MM)] - - for obj in regions: - # Convert the region to a shapely line string - ls = LineString(obj.outline).normalize() - - # Ask shapely to offset the line string by 1 mm - out = ls.offset_curve(obj.unit(1, 'mm')) - - # For negative offsets, this operation can result in an object being split up into multiple parts, so we - # might get back a MultiLineString instead of a LineString. - for ls in (out.geoms if hasattr(out, 'geoms') else [out]): - - # Convert the resulting shapely object back to a Gerber region. - new.append(graphic_objects.Region( - unit=obj.unit, - polarity_dark=not obj.polarity_dark, - outline=list(ls.coords))) - - # Append the new objects to the original layer data - l.objects = new + l.objects - # Write the modified layer stack to a new Gerber directory - stack.save_to_directory('output-gerbers') diff --git a/docs/index.rst b/docs/index.rst index 22c2361..45dbc1b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,7 +48,6 @@ Features cli api-concepts - examples file-api object-api apertures @@ -71,12 +70,10 @@ Then, you are ready to read and write gerber files: from gerbonara import LayerStack - stack = LayerStack.open('output/gerber') + stack = LayerStack.from_directory('output/gerber') w, h = stack.outline.size('mm') print(f'Board size is {w:.1f} mm x {h:.1f} mm') -You can find some more elaborate examples in this doc's :ref:`Examples section`. - Command-Line Interface ====================== diff --git a/examples/highlight_outline.py b/examples/highlight_outline.py index 0f600e3..9d89ce2 100644 --- a/examples/highlight_outline.py +++ b/examples/highlight_outline.py @@ -10,7 +10,7 @@ from gerbonara.utils import MM from gerbonara.utils import rotate_point def highlight_outline(input_dir, output_dir): - stack = LayerStack.open(input_dir) + stack = LayerStack.from_directory(input_dir) outline = [] for obj in stack.outline.objects: @@ -28,6 +28,7 @@ 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 diff --git a/examples/load_directory.py b/examples/load_directory.py index a23f519..6e1f901 100644 --- a/examples/load_directory.py +++ b/examples/load_directory.py @@ -7,5 +7,5 @@ if __name__ == '__main__': args = parser.parse_args() import gerbonara - print(gerbonara.LayerStack.open(args.input)) + print(gerbonara.LayerStack.from_directory(args.input)) diff --git a/examples/test_arc_approx.py b/examples/test_arc_approx.py index a93864a..76d4116 100644 --- a/examples/test_arc_approx.py +++ b/examples/test_arc_approx.py @@ -2,7 +2,6 @@ import math -from gerbonara.utils import MM from gerbonara.graphic_objects import Arc from gerbonara.graphic_objects import rotate_point @@ -23,8 +22,7 @@ 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, unit=MM) + arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None, polarity_dark=True) lines = arc.approximate(max_error=max_error) print(f' and self.unit == 'inch' + return UnitExpression(self._expr + (other._expr / MILLIMETERS_PER_INCH), self.unit) + else: # other.unit == 'inch' and self.unit == 'mm' + return UnitExpression(self._expr + (other._expr * MILLIMETERS_PER_INCH), self.unit) + + def __radd__(self, other): + # left hand side cannot have been an UnitExpression or __radd__ would not have been called + raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.') + + def __sub__(self, other): + return (self + (-other)).optimize() + + def __rsub__(self, other): + # see __radd__ above + raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.') + + def __mul__(self, other): + return UnitExpression(self._expr * other, self.unit) + + def __rmul__(self, other): + return UnitExpression(other * self._expr, self.unit) + + def __truediv__(self, other): + return UnitExpression(self._expr / other, self.unit) + + def __rtruediv__(self, other): + return UnitExpression(other / self._expr, self.unit) + + def __neg__(self): + return UnitExpression(-self._expr, self.unit) + + def __pos__(self): + return self + + +class ConstantExpression(Expression): + def __init__(self, value): + self.value = value + + def __float__(self): + return float(self.value) + + def __eq__(self, other): + return type(self) == type(other) and self.value == other.value + + def to_gerber(self, _unit=None): + return f'{self.value:.6f}'.rstrip('0').rstrip('.') + + +class VariableExpression(Expression): + def __init__(self, number): + self.number = number + + def optimized(self, variable_binding={}): + if self.number in variable_binding: + return ConstantExpression(variable_binding[self.number]) + return self + + def __eq__(self, other): + return type(self) == type(other) and \ + self.number == other.number + + def to_gerber(self, _unit=None): + return f'${self.number}' + + +class OperatorExpression(Expression): + def __init__(self, op, l, r): + self.op = op + self.l = ConstantExpression(l) if isinstance(l, (int, float)) else l + self.r = ConstantExpression(r) if isinstance(r, (int, float)) else r + + def __eq__(self, other): + return type(self) == type(other) and \ + self.op == other.op and \ + self.l == other.l and \ + self.r == other.r + + def optimized(self, variable_binding={}): + l = self.l.optimized(variable_binding) + r = self.r.optimized(variable_binding) + + if self.op in (operator.add, operator.mul): + if id(r) < id(l): + l, r = r, l + + if isinstance(l, ConstantExpression) and isinstance(r, ConstantExpression): + return ConstantExpression(self.op(float(l), float(r))) + + return OperatorExpression(self.op, l, r) + + def to_gerber(self, unit=None): + lval = self.l.to_gerber(unit) + rval = self.r.to_gerber(unit) + + if isinstance(self.l, OperatorExpression): + lval = f'({lval})' + if isinstance(self.r, OperatorExpression): + rval = f'({rval})' + + op = {operator.add: '+', + operator.sub: '-', + operator.mul: 'x', + operator.truediv: '/'} [self.op] + + return f'{lval}{op}{rval}' + diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py new file mode 100644 index 0000000..f0ff8d6 --- /dev/null +++ b/gerbonara/aperture_macros/parse.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2021 Jan Sebastian Götte + +import operator +import re +import ast +import copy +import math + +from . import primitive as ap +from .expression import * +from ..utils import MM + +def rad_to_deg(x): + return (x / math.pi) * 180 + +def _map_expression(node): + 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), _map_expression(node.right)) + + elif isinstance(node, ast.UnaryOp): + if type(node.op) == ast.UAdd: + return _map_expression(node.operand) + else: + return OperatorExpression(operator.sub, ConstantExpression(0), _map_expression(node.operand)) + + elif isinstance(node, ast.Name): + return VariableExpression(int(node.id[3:])) # node.id has format var[0-9]+ + + else: + raise SyntaxError('Invalid aperture macro expression') + +def _parse_expression(expr): + 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) + +class ApertureMacro: + def __init__(self, name=None, primitives=None, variables=None): + self._name = name + self.comments = [] + self.variables = variables or {} + self.primitives = primitives or [] + + @classmethod + def parse_macro(cls, name, body, unit): + macro = cls(name) + + blocks = body.split('*') + for block in blocks: + if not (block := block.strip()): # empty block + continue + + if block.startswith('0 '): # comment + macro.comments.append(block[2:]) + continue + + block = re.sub(r'\s', '', block) + + if block[0] == '$': # variable definition + name, expr = block.partition('=') + number = int(name[1:]) + if number in macro.variables: + raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro') + macro.variables[number] = _parse_expression(expr) + + else: # primitive + primitive, *args = block.split(',') + args = [ _parse_expression(arg) for arg in args ] + primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args) + macro.primitives.append(primitive) + + return macro + + @property + def name(self): + if self._name is not None: + return self._name + else: + return f'gn_{hash(self)}' + + @name.setter + def name(self, name): + self._name = name + + def __str__(self): + return f'' + + def __repr__(self): + return str(self) + + def __eq__(self, other): + return hasattr(other, 'to_gerber') and self.to_gerber() == other.to_gerber() + + def __hash__(self): + return hash(self.to_gerber()) + + def dilated(self, offset, unit=MM): + dup = copy.deepcopy(self) + new_primitives = [] + for primitive in dup.primitives: + try: + if primitive.exposure.calculate(): + primitive.dilate(offset, unit) + new_primitives.append(primitive) + except IndexError: + warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.') + pass + dup.primitives = new_primitives + return dup + + def to_gerber(self, unit=None): + comments = [ c.to_gerber() for c in self.comments ] + variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in self.variables.items() ] + primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ] + return '*\n'.join(comments + variable_defs + primitive_defs) + + def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True): + variables = dict(self.variables) + for number, value in enumerate(parameters, start=1): + if number in variables: + raise SyntaxError(f'Re-definition of aperture macro variable {i} through parameter {value}') + variables[number] = value + + for primitive in self.primitives: + yield from primitive.to_graphic_primitives(offset, rotation, variables, unit, polarity_dark) + + def rotated(self, angle): + dup = copy.deepcopy(self) + for primitive in dup.primitives: + # aperture macro primitives use degree counter-clockwise, our API uses radians clockwise + primitive.rotation -= rad_to_deg(angle) + return dup + + def scaled(self, scale): + dup = copy.deepcopy(self) + for primitive in dup.primitives: + primitive.scale(scale) + return dup + + +var = VariableExpression +deg_per_rad = 180 / math.pi + +class GenericMacros: + + _generic_hole = lambda n: [ + ap.Circle('mm', [0, var(n), 0, 0]), + ap.CenterLine('mm', [0, var(n), var(n+1), 0, 0, var(n+2) * -deg_per_rad])] + + # NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing + # API. + circle = ApertureMacro('GNC', [ + ap.Circle('mm', [1, var(1), 0, 0, var(4) * -deg_per_rad]), + *_generic_hole(2)]) + + rect = ApertureMacro('GNR', [ + 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', [ + 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)), 0]), + ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]), + ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]), + ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]), + *_generic_hole(4)]) + + # w must be larger than h + obround = ApertureMacro('GNO', [ + ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]), + ap.Circle('mm', [1, var(2), +var(1)/2, 0, var(5) * -deg_per_rad]), + ap.Circle('mm', [1, var(2), -var(1)/2, 0, var(5) * -deg_per_rad]), + *_generic_hole(3) ]) + + polygon = ApertureMacro('GNP', [ + 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) + diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py new file mode 100644 index 0000000..f9b5a78 --- /dev/null +++ b/gerbonara/aperture_macros/primitive.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2019 Hiroshi Murayama +# Copyright 2022 Jan Sebastian Götte + +import warnings +import contextlib +import math + +from .expression import Expression, UnitExpression, ConstantExpression, expr + +from .. import graphic_primitives as gp + + +def point_distance(a, b): + x1, y1 = a + x2, y2 = b + return math.sqrt((x2 - x1)**2 + (y2 - y1)**2) + +def deg_to_rad(a): + return (a / 180) * math.pi + +class Primitive: + def __init__(self, unit, args): + self.unit = unit + + if len(args) > len(type(self).__annotations__): + raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})') + + for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()): + arg = expr(arg) # convert int/float to Expression object + + if fieldtype == UnitExpression: + setattr(self, name, UnitExpression(arg, unit)) + else: + setattr(self, name, arg) + + for name in type(self).__annotations__: + if not hasattr(self, name): + raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})') + + def to_gerber(self, unit=None): + return f'{self.code},' + ','.join( + getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + + def __str__(self): + attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) + return f'<{type(self).__name__} {attrs}>' + + def __repr__(self): + return str(self) + + class Calculator: + def __init__(self, instance, variable_binding={}, unit=None): + self.instance = instance + self.variable_binding = variable_binding + self.unit = unit + + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + pass + + def __getattr__(self, name): + return getattr(self.instance, name).calculate(self.variable_binding, self.unit) + + def __call__(self, expr): + return expr.calculate(self.variable_binding, self.unit) + + +class Circle(Primitive): + code = 1 + exposure : Expression + diameter : UnitExpression + # center x/y + x : UnitExpression + y : UnitExpression + rotation : Expression = None + + def __init__(self, unit, args): + super().__init__(unit, args) + if self.rotation is None: + self.rotation = ConstantExpression(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 = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] + + def dilate(self, offset, unit): + self.diameter += UnitExpression(offset, unit) + + def scale(self, scale): + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) + self.diameter *= UnitExpression(scale) + + +class VectorLine(Primitive): + code = 20 + exposure : Expression + width : UnitExpression + start_x : UnitExpression + start_y : UnitExpression + end_x : UnitExpression + end_y : UnitExpression + rotation : Expression + + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): + with self.Calculator(self, variable_binding, unit) as calc: + center_x = (calc.end_x + calc.start_x) / 2 + center_y = (calc.end_y + calc.start_y) / 2 + delta_x = calc.end_x - calc.start_x + delta_y = calc.end_y - calc.start_y + length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y)) + + center_x, center_y = center_x+offset[0], center_y+offset[1] + rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x) + + return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation, + polarity_dark=(bool(calc.exposure) == polarity_dark)) ] + + def dilate(self, offset, unit): + self.width += UnitExpression(2*offset, unit) + + def scale(self, scale): + self.start_x *= UnitExpression(scale) + self.start_y *= UnitExpression(scale) + self.end_x *= UnitExpression(scale) + self.end_y *= UnitExpression(scale) + + +class CenterLine(Primitive): + code = 21 + exposure : Expression + width : UnitExpression + height : UnitExpression + # center x/y + x : UnitExpression + y : UnitExpression + rotation : Expression + + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): + with self.Calculator(self, variable_binding, unit) as calc: + rotation += deg_to_rad(calc.rotation) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + w, h = calc.width, calc.height + + return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] + + def dilate(self, offset, unit): + self.width += UnitExpression(2*offset, unit) + + def scale(self, scale): + self.width *= UnitExpression(scale) + self.height *= UnitExpression(scale) + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) + + +class Polygon(Primitive): + code = 5 + exposure : Expression + n_vertices : Expression + # center x/y + x : UnitExpression + y : UnitExpression + diameter : UnitExpression + rotation : Expression + + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): + with self.Calculator(self, variable_binding, unit) as calc: + rotation += deg_to_rad(calc.rotation) + x, y = gp.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, + polarity_dark=(bool(calc.exposure) == polarity_dark)) ] + + def dilate(self, offset, unit): + self.diameter += UnitExpression(2*offset, unit) + + def scale(self, scale): + self.diameter *= UnitExpression(scale) + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) + + +class Thermal(Primitive): + code = 7 + exposure : Expression + # center x/y + x : UnitExpression + y : UnitExpression + d_outer : UnitExpression + d_inner : UnitExpression + gap_w : UnitExpression + rotation : Expression + + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): + with self.Calculator(self, variable_binding, unit) as calc: + rotation += deg_to_rad(calc.rotation) + x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) + x, y = x+offset[0], y+offset[1] + + dark = (bool(calc.exposure) == polarity_dark) + + 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), + ] + + def dilate(self, offset, unit): + # I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than + # producing macros that may evaluate to primitives with negative values. + warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.') + + def scale(self, scale): + self.d_outer *= UnitExpression(scale) + self.d_inner *= UnitExpression(scale) + self.gap_w *= UnitExpression(scale) + self.x *= UnitExpression(scale) + self.y *= UnitExpression(scale) + + +class Outline(Primitive): + code = 4 + + def __init__(self, unit, args): + if len(args) < 11: + raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).') + if len(args) > 5004: + raise ValueError(f'Invalid aperture macro outline primitive, too many points ({len(args)//2-2}).') + + self.exposure = args.pop(0) + + # length arg must not contain variables (that would not make sense) + length_arg = args.pop(0).calculate() + + if length_arg != len(args)//2-1: + raise ValueError(f'Invalid aperture macro outline primitive, given size {length_arg} does not match length of coordinate list({len(args)//2-1}).') + + if len(args) % 2 == 1: + self.rotation = args.pop() + else: + self.rotation = ConstantExpression(0.0) + + if args[0] != args[-2] or args[1] != args[-1]: + raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}') + + self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[0::2], args[1::2])] + + def __str__(self): + return f'' + + def to_gerber(self, unit=None): + coords = ','.join(coord.to_gerber(unit) for xy in self.coords for coord in xy) + return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)-1},{coords},{self.rotation.to_gerber()}' + + def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): + with self.Calculator(self, variable_binding, unit) as calc: + rotation += deg_to_rad(calc.rotation) + bound_coords = [ gp.rotate_point(calc(x), calc(y), rotation, 0, 0) for x, y in self.coords ] + bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ] + bound_radii = [None] * len(bound_coords) + return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))] + + def dilate(self, offset, unit): + # we would need a whole polygon offset/clipping library here + warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.') + + def scale(self, scale): + self.coords = [(x*UnitExpression(scale), y*UnitExpression(scale)) for x, y in self.coords] + + +class Comment: + code = 0 + + def __init__(self, comment): + self.comment = comment + + def to_gerber(self, unit=None): + return f'0 {self.comment}' + + def scale(self, scale): + pass + +PRIMITIVE_CLASSES = { + **{cls.code: cls for cls in [ + Comment, + Circle, + VectorLine, + CenterLine, + Outline, + Polygon, + Thermal, + ]}, + # alternative codes + 2: VectorLine, +} + diff --git a/src/gerbonara/apertures.py b/gerbonara/apertures.py similarity index 63% rename from src/gerbonara/apertures.py rename to gerbonara/apertures.py index fae129e..b411412 100644 --- a/src/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -16,18 +16,21 @@ # limitations under the License. # -import warnings import math -from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY -from functools import lru_cache +from dataclasses import dataclass, replace, field, fields, InitVar -from .utils import LengthUnit, MM, Inch, sum_bounds +from .aperture_macros.parse import GenericMacros +from .utils import MM, Inch from . import graphic_primitives as gp def _flash_hole(self, x, y, unit=None, polarity_dark=True): - if self.hole_dia is not None: + if getattr(self, 'hole_rect_h', None) is not None: + w, h = self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h) + return [*self._primitives(x, y, unit, polarity_dark), + gp.Rectangle(x, y, w, h, rotation=self.rotation, polarity_dark=(not polarity_dark))] + elif self.hole_dia is not None: return [*self._primitives(x, y, unit, polarity_dark), gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))] else: @@ -37,7 +40,7 @@ def _strip_right(*args): args = list(args) while args and args[-1] is None: args.pop() - return tuple(args) + return args def _none_close(a, b): if a is None and b is None: @@ -54,14 +57,39 @@ class Length: def __init__(self, obj_type): self.type = obj_type -@dataclass(frozen=True, slots=True) +@dataclass class Aperture: """ Base class for all apertures. """ - _ : KW_ONLY - unit: LengthUnit = None - attrs: tuple = None - original_number: int = field(default=None, hash=False, compare=False) - _bounding_box: tuple = field(default=None, hash=False, compare=False) + + # hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY. + # + # For details, refer to graphic_objects.py + def __init_subclass__(cls): + #: :py:class:`gerbonara.utils.LengthUnit` used for all length fields of this aperture. + cls.unit = None + #: GerberX2 attributes of this aperture. Note that this will only contain aperture attributes, not file attributes. + #: File attributes are stored in the :py:attr:`~.GerberFile.attrs` of the :py:class:`.GerberFile`. + cls.attrs = field(default_factory=dict) + #: Aperture index this aperture had when it was read from the Gerber file. This field is purely informational since + #: apertures are de-duplicated and re-numbered when writing a Gerber file. For `D10`, this field would be `10`. When + #: you programmatically create a new aperture, you do not have to set this. + cls.original_number = None + + d = {'unit': str, 'attrs': dict, 'original_number': int} + if hasattr(cls, '__annotations__'): + cls.__annotations__.update(d) + else: + cls.__annotations__ = d + + @property + def hole_shape(self): + """ Get shape of hole based on :py:attr:`hole_dia` and :py:attr:`hole_rect_h`: "rect" or "circle" or None. """ + if getattr(self, 'hole_rect_h') is not None: + return 'rect' + elif getattr(self, 'hole_dia') is not None: + return 'circle' + else: + return None def _params(self, unit=None): out = [] @@ -90,12 +118,6 @@ class Aperture: """ return self._primitives(x, y, unit, polarity_dark) - def bounding_box(self, unit=None): - if self._bounding_box is None: - object.__setattr__(self, '_bounding_box', - sum_bounds((prim.bounding_box() for prim in self.flash(0, 0, MM, True)))) - return MM.convert_bounds_to(unit, self._bounding_box) - def equivalent_width(self, unit=None): """ Get the width of a line interpolated using this aperture in the given :py:class:`~.LengthUnit`. @@ -108,12 +130,16 @@ class Aperture: :rtype: str """ + # Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use, + # we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at + # export time during to_gerber, this parameter is evaluated. unit = settings.unit if settings else None - params = 'X'.join(f'{float(par):.4}' for par in self._params(unit) if par is not None) + actual_inst = self.rotated() + params = 'X'.join(f'{float(par):.4}' for par in actual_inst._params(unit) if par is not None) if params: - return f'{self._gerber_shape_code},{params}' + return f'{actual_inst._gerber_shape_code},{params}' else: - return self._gerber_shape_code + return actual_inst._gerber_shape_code def to_macro(self): """ Convert this :py:class:`.Aperture` into an :py:class:`.ApertureMacro` inside an @@ -121,10 +147,24 @@ class Aperture: """ raise NotImplementedError() -@dataclass(frozen=True, slots=True) + def __eq__(self, other): + """ Compare two apertures. Apertures are compared based on their Gerber representation. Two apertures are + considered equal if their Gerber aperture definitions are identical. + """ + # We need to choose some unit here. + return hasattr(other, 'to_gerber') and self.to_gerber(MM) == other.to_gerber(MM) + + def _rotate_hole_90(self): + if self.hole_rect_h is None: + return {'hole_dia': self.hole_dia, 'hole_rect_h': None} + else: + return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia} + +@dataclass(unsafe_hash=True) class ExcellonTool(Aperture): """ Special Aperture_ subclass for use in :py:class:`.ExcellonFile`. Similar to :py:class:`.CircleAperture`, but - does not have :py:attr:`.CircleAperture.hole_dia`, and has the additional :py:attr:`plated` attribute. + does not have :py:attr:`.CircleAperture.hole_dia` or :py:attr:`.CircleAperture.hole_rect_h`, and has the additional + :py:attr:`plated` attribute. """ _gerber_shape_code = 'C' _human_readable_shape = 'drill' @@ -140,6 +180,18 @@ class ExcellonTool(Aperture): def to_xnc(self, settings): return 'C' + settings.write_excellon_value(self.diameter, self.unit) + def __eq__(self, other): + """ Compare two :py:class:`.ExcellonTool` instances. They are considered equal if their diameter and plating + match. + """ + if not isinstance(other, ExcellonTool): + return False + + if not self.plated == other.plated: + return False + + return _none_close(self.diameter, self.unit(other.diameter, other.unit)) + def __str__(self): plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated') return f'' @@ -150,23 +202,19 @@ class ExcellonTool(Aperture): # Internal use, for layer dilation. def dilated(self, offset, unit=MM): offset = unit(offset, self.unit) - if math.isclose(offset, 0, abs_tol=1e-6): - return self return replace(self, diameter=self.diameter+2*offset) - @lru_cache() def rotated(self, angle=0): return self - def to_macro(self, rotation=0): - from .aperture_macros.parse import GenericMacros - return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM) + def to_macro(self): + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) def _params(self, unit=None): - return (self.unit.convert_to(unit, self.diameter),) + return [self.unit.convert_to(unit, self.diameter)] -@dataclass(frozen=True, slots=True) +@dataclass class CircleAperture(Aperture): """ Besides flashing circles or rings, CircleApertures are used to set the width of a :py:class:`~.graphic_objects.Line` or :py:class:`~.graphic_objects.Arc`. @@ -177,6 +225,10 @@ class CircleAperture(Aperture): diameter : Length(float) #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. hole_dia : Length(float) = None + #: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole. + hole_rect_h : Length(float) = None + # float with radians. This is only used for rectangular holes (as circles are rotationally symmetric). + rotation : float = 0 def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ] @@ -191,31 +243,31 @@ class CircleAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) - if math.isclose(offset, 0, abs_tol=1e-6): - return self - return replace(self, diameter=self.diameter+2*offset, hole_dia=None) + return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None) - @lru_cache() def rotated(self, angle=0): - return self + if math.isclose((self.rotation+angle) % (2*math.pi), 0, abs_tol=1e-6) or self.hole_rect_h is None: + return self + else: + return self.to_macro(self.rotation+angle) def scaled(self, scale): return replace(self, diameter=self.diameter*scale, - hole_dia=None if self.hole_dia is None else self.hole_dia*scale) + hole_dia=None if self.hole_dia is None else self.hole_dia*scale, + hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale) - def to_macro(self, rotation=0): - from .aperture_macros.parse import GenericMacros - return GenericMacros.circle(MM(self.diameter, self.unit), - MM(self.hole_dia, self.unit)) + def to_macro(self): + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) def _params(self, unit=None): return _strip_right( self.unit.convert_to(unit, self.diameter), - self.unit.convert_to(unit, self.hole_dia)) + self.unit.convert_to(unit, self.hole_dia), + self.unit.convert_to(unit, self.hole_rect_h)) -@dataclass(frozen=True, slots=True) +@dataclass class RectangleAperture(Aperture): """ Gerber rectangle aperture. Can only be used for flashes, since the line width of an interpolation of a rectangle aperture is not well-defined and there is no tool that implements it in a geometrically correct way. """ @@ -227,10 +279,14 @@ class RectangleAperture(Aperture): h : Length(float) #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. hole_dia : Length(float) = None + #: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole. + hole_rect_h : Length(float) = None + # Rotation in radians. This rotates both the aperture and the rectangular hole if it has one. + rotation : float = 0 # radians def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), - rotation=0, polarity_dark=polarity_dark) ] + rotation=self.rotation, polarity_dark=polarity_dark) ] def __str__(self): return f'' @@ -242,40 +298,42 @@ class RectangleAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) - if math.isclose(offset, 0, abs_tol=1e-6): - return self - return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None) + return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None) - @lru_cache() def rotated(self, angle=0): - if math.isclose(angle % math.pi, 0, abs_tol=1e-6): + self.rotation += angle + if math.isclose(self.rotation % math.pi, 0): + self.rotation = 0 return self - elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6): - return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) + elif math.isclose(self.rotation % math.pi, math.pi/2): + return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0) else: # odd angle - return self.to_macro(angle) + return self.to_macro() def scaled(self, scale): return replace(self, w=self.w*scale, h=self.h*scale, - hole_dia=None if self.hole_dia is None else self.hole_dia*scale) + hole_dia=None if self.hole_dia is None else self.hole_dia*scale, + hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale) def to_macro(self, rotation=0): - 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) + return ApertureMacroInstance(GenericMacros.rect, + [MM(self.w, self.unit), + MM(self.h, self.unit), + MM(self.hole_dia, self.unit) or 0, + MM(self.hole_rect_h, self.unit) or 0, + self.rotation + rotation]) def _params(self, unit=None): return _strip_right( self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), - self.unit.convert_to(unit, self.hole_dia)) + self.unit.convert_to(unit, self.hole_dia), + self.unit.convert_to(unit, self.hole_rect_h)) -@dataclass(frozen=True, slots=True) +@dataclass class ObroundAperture(Aperture): """ Aperture whose shape is the convex hull of two circles of equal radii. @@ -291,10 +349,14 @@ class ObroundAperture(Aperture): h : Length(float) #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. hole_dia : Length(float) = None + #: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole. + hole_rect_h : Length(float) = None + #: Rotation in radians. This rotates both the aperture and the rectangular hole if it has one. + rotation : float = 0 def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.Line.from_obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), - polarity_dark=polarity_dark) ] + rotation=self.rotation, polarity_dark=polarity_dark) ] def __str__(self): return f'' @@ -303,16 +365,13 @@ class ObroundAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) - if math.isclose(offset, 0, abs_tol=1e-6): - return self - return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None) + return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None) - @lru_cache() def rotated(self, angle=0): - if math.isclose(angle % math.pi, 0, abs_tol=1e-6): + if math.isclose((angle + self.rotation) % math.pi, 0, abs_tol=1e-6): return self - elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6): - return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) + elif math.isclose((angle + self.rotation) % math.pi, math.pi/2, abs_tol=1e-6): + return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0) else: return self.to_macro(angle) @@ -320,30 +379,32 @@ class ObroundAperture(Aperture): return replace(self, w=self.w*scale, h=self.h*scale, - hole_dia=None if self.hole_dia is None else self.hole_dia*scale) + hole_dia=None if self.hole_dia is None else self.hole_dia*scale, + hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale) def to_macro(self, rotation=0): # generic macro only supports w > h so flip x/y if h > w if self.w > self.h: inst = self else: - rotation -= -math.pi/2 - inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) + inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=rotation+self.rotation-90) - 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) + return ApertureMacroInstance(GenericMacros.obround, + [MM(inst.w, self.unit), + MM(inst.h, self.unit), + MM(inst.hole_dia, self.unit), + MM(inst.hole_rect_h, self.unit), + inst.rotation]) def _params(self, unit=None): return _strip_right( self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), - self.unit.convert_to(unit, self.hole_dia)) + self.unit.convert_to(unit, self.hole_dia), + self.unit.convert_to(unit, self.hole_rect_h)) -@dataclass(frozen=True, slots=True) +@dataclass class PolygonAperture(Aperture): """ Aperture whose shape is a regular n-sided polygon (e.g. pentagon, hexagon etc.). Note that this only supports round holes. @@ -360,7 +421,7 @@ class PolygonAperture(Aperture): hole_dia : Length(float) = None def __post_init__(self): - object.__setattr__(self, 'n_vertices', int(self.n_vertices)) + self.n_vertices = int(self.n_vertices) def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.ArcPoly.from_regular_polygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices, @@ -371,16 +432,13 @@ class PolygonAperture(Aperture): def dilated(self, offset, unit=MM): offset = self.unit(offset, unit) - if math.isclose(offset, 0, abs_tol=1e-6): - return self return replace(self, diameter=self.diameter+2*offset, hole_dia=None) flash = _flash_hole - @lru_cache() def rotated(self, angle=0): if angle != 0: - return replace(self, rotation=self.rotation + angle) + return replace(self, rotatio=self.rotation + angle) else: return self @@ -390,27 +448,21 @@ class PolygonAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*scale) def to_macro(self): - 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) + return ApertureMacroInstance(GenericMacros.polygon, self._params(MM)) 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) - elif rotation is not None and not math.isclose(rotation, 0, abs_tol=1e-6): + elif rotation is not None and not math.isclose(rotation, 0): return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation else: return self.unit.convert_to(unit, self.diameter), self.n_vertices -@dataclass(frozen=True, slots=True) +@dataclass class ApertureMacroInstance(Aperture): """ One instance of an aperture macro. An aperture macro defined with an ``AM`` statement can be instantiated by multiple ``AD`` aperture definition statements using different parameters. An :py:class:`.ApertureMacroInstance` is @@ -422,7 +474,10 @@ class ApertureMacroInstance(Aperture): macro : object #: The parameters to the :py:class:`.ApertureMacro`. All elements should be floats or ints. The first item in the #: list is parameter ``$1``, the second is ``$2`` etc. - parameters : tuple = () + parameters : list + #: Aperture rotation in radians. When saving, a copy of the :py:class:`.ApertureMacro` is re-written with this + #: rotation. + rotation : float = 0 @property def _gerber_shape_code(self): @@ -430,39 +485,33 @@ class ApertureMacroInstance(Aperture): def _primitives(self, x, y, unit=None, polarity_dark=True): out = list(self.macro.to_graphic_primitives( - offset=(x, y), rotation=0, + offset=(x, y), rotation=self.rotation, parameters=self.parameters, unit=unit, polarity_dark=polarity_dark)) return out def dilated(self, offset, unit=MM): - if math.isclose(offset, 0, abs_tol=1e-6): - return self return replace(self, macro=self.macro.dilated(offset, unit)) - @lru_cache() - def rotated(self, angle=0.0): - if math.isclose(angle % (2*math.pi), 0, abs_tol=1e-6): + def rotated(self, angle=0): + if math.isclose((self.rotation+angle) % (2*math.pi), 0): return self else: return self.to_macro(angle) - def to_macro(self, rotation=0.0): - return replace(self, macro=self.macro.rotated(rotation)) + def to_macro(self, rotation=0): + return replace(self, macro=self.macro.rotated(self.rotation+rotation), rotation=0) def scaled(self, scale): return replace(self, macro=self.macro.scaled(scale)) - def calculate_out(self, unit=None, macro_name=None): - return replace(self, - parameters=tuple(), - macro=self.macro.substitute_params(self._params(unit), unit, macro_name)) + def __eq__(self, other): + return hasattr(other, 'macro') and self.macro == other.macro and \ + hasattr(other, 'parameters') and self.parameters == other.parameters and \ + hasattr(other, 'rotation') and self.rotation == other.rotation def _params(self, unit=None): # We ignore "unit" here as we convert the actual macro, not this instantiation. # 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(f'Aperture definition using macro {self.macro.name} has more parameters than the macro uses.') - parameters = parameters[:self.macro.num_parameters] - return tuple(parameters) + return tuple(self.parameters) + diff --git a/src/gerbonara/cad/data/__init__.py b/gerbonara/cad/__init__.py similarity index 100% rename from src/gerbonara/cad/data/__init__.py rename to gerbonara/cad/__init__.py diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py new file mode 100644 index 0000000..840b9d5 --- /dev/null +++ b/gerbonara/cad/kicad/base_types.py @@ -0,0 +1,122 @@ +from .sexp import * +from .sexp_mapper import * +import time + +from dataclasses import field +import math +import uuid + + +@sexp_type('color') +class Color: + r: int = None + g: int = None + b: int = None + a: int = None + + +@sexp_type('stroke') +class Stroke: + width: Named(float) = 0.254 + type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default + color: Color = None + + @property + def width_mil(self): + return mm_to_mil(self.width) + + @width_mil.setter + def width_mil(self, value): + self.width = mil_to_mm(value) + + +@sexp_type('xy') +class XYCoord: + x: float = 0 + y: float = 0 + + def isclose(self, other, tol=1e-6): + return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol) + + +@sexp_type('pts') +class PointList: + xy : List(XYCoord) = field(default_factory=list) + + +@sexp_type('xyz') +class XYZCoord: + x: float = 0 + y: float = 0 + z: float = 0 + + +@sexp_type('at') +class AtPos(XYCoord): + x: float = 0 # in millimeter + y: float = 0 # in millimeter + rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270. + unlocked: Flag() = False + + def __before_sexp__(self): + self.rotation = int(round(self.rotation % 360)) + + @property + def rotation_rad(self): + return math.radians(self.rotation) + + @rotation_rad.setter + def rotation_rad(self, value): + self.rotation = math.degrees(value) + + +@sexp_type('font') +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 + line_spacing: Named(float) = None + + +@sexp_type('justify') +class Justify: + h: AtomChoice(Atom.left, Atom.right) = None + v: AtomChoice(Atom.top, Atom.bottom) = None + mirror: Flag() = False + + +@sexp_type('effects') +class TextEffect: + font: FontSpec = field(default_factory=FontSpec) + justify: OmitDefault(Justify) = field(default_factory=Justify) + hide: Flag() = False + + +@sexp_type('tstamp') +class Timestamp: + value: str = field(default_factory=uuid.uuid4) + + 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) + + def __after_parse__(self, parent): + self.value = int(str(self.value), 16) + + def __before_sexp__(self): + self.value = Atom(f'{int(self.value):08X}') + + def bump(self): + self.value = time.time() + diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py new file mode 100644 index 0000000..f76fd3f --- /dev/null +++ b/gerbonara/cad/kicad/footprints.py @@ -0,0 +1,316 @@ +""" +Library for handling KiCad's footprint files (`*.kicad_mod`). +""" + +import copy +import enum +import datetime +import math +import time +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union + +from .sexp import * +from .base_types import * +from .primitives import * +from . import graphical_primitives as gr + + +@sexp_type('property') +class Property: + key: str = '' + value: str = '' + + +@sexp_type('attr') +class Attribute: + type: AtomChoice(Atom.smd, Atom.through_hole) = None + board_only: Flag() = False + exclude_from_pos_files: Flag() = False + exclude_from_bom: Flag() = False + + +@sexp_type('fp_text') +class Text: + type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user + text: str = "" + at: AtPos = field(default_factory=AtPos) + unlocked: Flag() = False + layer: Named(str) = None + hide: Flag() = False + effects: TextEffect = field(default_factory=TextEffect) + tstamp: Timestamp = None + + +@sexp_type('fp_text_box') +class TextBox: + locked: Flag() = False + text: str = None + start: Rename(XYCoord) = None + end: Named(XYCoord) = None + pts: PointList = None + angle: Named(float) = 0.0 + layer: Named(str) = None + tstamp: Timestamp = None + effects: TextEffect = field(default_factory=TextEffect) + stroke: Stroke = field(default_factory=Stroke) + render_cache: RenderCache = None + + +@sexp_type('fp_line') +class Line: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + stroke: Stroke = None + locked: Flag() = False + tstamp: Timestamp = None + + +@sexp_type('fp_rect') +class Rectangle: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + stroke: Stroke = None + fill: Named(AtomChoice(Atom.solid, Atom.none)) = None + locked: Flag() = False + tstamp: Timestamp = None + + +@sexp_type('fp_circle') +class Circle: + center: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + stroke: Stroke = None + fill: Named(AtomChoice(Atom.solid, Atom.none)) = None + locked: Flag() = False + tstamp: Timestamp = None + + +@sexp_type('fp_arc') +class Arc: + start: Rename(XYCoord) = None + mid: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + stroke: Stroke = None + locked: Flag() = False + tstamp: Timestamp = None + + +@sexp_type('fp_poly') +class Polygon: + pts: PointList = field(default_factory=PointList) + layer: Named(str) = None + width: Named(float) = None + stroke: Stroke = None + fill: Named(AtomChoice(Atom.solid, Atom.none)) = None + locked: Flag() = False + tstamp: Timestamp = None + + +@sexp_type('fp_curve') +class Curve: + pts: PointList = field(default_factory=PointList) + layer: Named(str) = None + width: Named(float) = None + stroke: Stroke = None + locked: Flag() = False + tstamp: Timestamp = None + + +@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) + + +@sexp_type('drill') +class Drill: + oval: Flag() = False + diameter: float = 0 + width: float = None + 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 + anchor: Named(AtomChoice(Atom.rect, Atom.circle)) = Atom.rect + + +@sexp_type('primitives') +class CustomPadPrimitives: + annotation_bboxes: List(gr.AnnotationBBox) = field(default_factory=list) + lines: List(gr.Line) = 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) + polygons: List(gr.Polygon) = field(default_factory=list) + curves: List(gr.Curve) = field(default_factory=list) + width: Named(float) = None + fill: Named(YesNoAtom()) = True + + +@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: + 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 + at: AtPos = field(default_factory=AtPos) + locked: Wrap(Flag()) = False + size: Rename(XYCoord) = field(default_factory=XYCoord) + 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 + rect_delta: Rename(XYCoord) = None + roundrect_rratio: Named(float) = None + thermal_bridge_angle: Named(int) = 45 + chamfer_ratio: Named(float) = None + chamfer: Chamfer = None + net: NetDef = None + tstamp: Timestamp = None + pin_function: Named(str) = None + pintype: Named(str) = None + die_length: Named(float) = None + solder_mask_margin: Named(float) = None + solder_paste_margin: Named(float) = None + solder_paste_margin_ratio: Named(float) = None + clearance: Named(float) = None + zone_connect: Named(int) = None + thermal_width: Named(float) = None + thermal_gap: Named(float) = None + options: OmitDefault(CustomPadOptions) = None + primitives: OmitDefault(CustomPadPrimitives) = None + + +@sexp_type('group') +class Group: + name: str = "" + id: Named(str) = "" + members: Named(List(str)) = field(default_factory=list) + + +@sexp_type('model') +class Model: + name: str = '' + at: Named(XYZCoord) = field(default_factory=XYZCoord) + offset: Named(XYZCoord) = field(default_factory=XYZCoord) + scale: Named(XYZCoord) = field(default_factory=XYZCoord) + rotate: Named(XYZCoord) = field(default_factory=XYZCoord) + + +SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018] +@sexp_type('footprint') +class Footprint: + name: str = None + _version: Named(int, name='version') = 20210108 + generator: Named(Atom) = Atom.kicad_library_utils + locked: Flag() = False + placed: Flag() = False + layer: Named(str) = 'F.Cu' + tedit: EditTime = field(default_factory=EditTime) + tstamp: Timestamp = None + at: AtPos = field(default_factory=AtPos) + descr: Named(str) = None + tags: Named(str) = None + properties: List(Property) = field(default_factory=list) + path: Named(str) = None + autoplace_cost90: Named(float) = None + autoplace_cost180: Named(float) = None + solder_mask_margin: Named(float) = None + solder_paste_margin: Named(float) = None + solder_paste_ratio: Named(float) = None + clearance: Named(float) = None + zone_connect: Named(int) = None + thermal_width: Named(float) = None + thermal_gap: Named(float) = None + attributes: List(Attribute) = field(default_factory=list) + private_layers: Named(str) = None + net_tie_pad_groups: Named(str) = None + texts: List(Text) = field(default_factory=list) + text_boxes: List(TextBox) = field(default_factory=list) + lines: List(Line) = field(default_factory=list) + rectangles: List(Rectangle) = field(default_factory=list) + circles: List(Circle) = field(default_factory=list) + 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) + pads: List(Pad) = field(default_factory=list) + zones: List(Zone) = field(default_factory=list) + groups: List(Group) = field(default_factory=list) + models: List(Model) = field(default_factory=list) + _ : SEXP_END = None + original_filename: str = None + + @property + def version(self): + return self._version + + @version.setter + def version(self, value): + if value not in SUPPORTED_FILE_FORMAT_VERSIONS: + raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') + + @classmethod + def open(cls, filename: str) -> 'Library': + with open(filename) as f: + return cls.parse(f.read()) + + def write(self, filename=None) -> None: + with open(filename or self.original_filename, 'w') as f: + f.write(build_sexp(sexp(self))) + + diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py new file mode 100644 index 0000000..391b38b --- /dev/null +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -0,0 +1,111 @@ + +from .sexp import * +from .base_types import * +from .primitives import * + +@sexp_type('layer') +class TextLayer: + layer: str = '' + knockout: Flag() = False + + +@sexp_type('gr_text') +class Text: + text: str = '' + at: AtPos = field(default_factory=AtPos) + layer: TextLayer = field(default_factory=TextLayer) + tstamp: Timestamp = None + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('gr_text_box') +class TextBox: + locked: Flag() = False + text: str = '' + start: Named(XYCoord) = None + end: Named(XYCoord) = None + pts: PointList = field(default_factory=PointList) + angle: OmitDefault(Named(float)) = 0.0 + layer: Named(str) = "" + tstamp: Timestamp = None + effects: TextEffect = field(default_factory=TextEffect) + stroke: Stroke = field(default_factory=Stroke) + render_cache: RenderCache = None + + +@sexp_type('gr_line') +class Line: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + angle: Named(float) = None + layer: Named(str) = None + width: Named(float) = None + tstamp: Timestamp = None + + +@sexp_type('fill') +class FillMode: + # Needed for compatibility with weird files + fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False + + @classmethod + def __map__(self, obj, parent=None): + return obj[0] in (Atom.solid, Atom.yes) + + @classmethod + def __sexp__(self, value): + yield [Atom.fill, Atom.solid if value else Atom.none] + +@sexp_type('gr_rect') +class Rectangle: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + fill: FillMode = False + tstamp: Timestamp = None + + +@sexp_type('gr_circle') +class Circle: + center: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + fill: FillMode = False + tstamp: Timestamp = None + + +@sexp_type('gr_arc') +class Arc: + start: Rename(XYCoord) = None + mid: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + tstamp: Timestamp = None + + +@sexp_type('gr_poly') +class Polygon: + pts: PointList = field(default_factory=PointList) + layer: Named(str) = None + width: Named(float) = None + fill: FillMode= False + tstamp: Timestamp = None + + +@sexp_type('gr_curve') +class Curve: + pts: PointList = field(default_factory=PointList) + layer: Named(str) = None + width: Named(float) = None + tstamp: Timestamp = None + + +@sexp_type('gr_bbox') +class AnnotationBBox: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + + diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py new file mode 100644 index 0000000..30ae611 --- /dev/null +++ b/gerbonara/cad/kicad/primitives.py @@ -0,0 +1,97 @@ + +import enum + +from .sexp import * +from .base_types import * + + +@sexp_type('hatch') +class Hatch: + style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge + pitch: float = 0.5 + + +@sexp_type('connect_pads') +class PadConnection: + type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None + clearance: Named(float) = 0 + + +@sexp_type('keepout') +class ZoneKeepout: + tracks_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='tracks') = True + vias_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='vias') = True + pads_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='pads') = True + copperpour_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='copperpour') = True + footprints_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='footprints') = True + + +@sexp_type('smoothing') +class ZoneSmoothing: + style: AtomChoice(Atom.chamfer, Atom.fillet) = Atom.chamfer + radius: Named(float) = None + + +@sexp_type('fill') +class ZoneFill: + yes: Flag() = False + mode: Flag(atom=Atom.hatched) = False + thermal_gap: Named(float) = 0.508 + thermal_bridge_width: Named(float) = 0.508 + smoothing: ZoneSmoothing = None + island_removal_node: Named(int) = None + islan_area_min: Named(float) = None + hatch_thickness: Named(float) = None + hatch_gap: Named(float) = None + hatch_orientation: Named(int) = None + hatch_smoothing_level: Named(int) = None + hatch_smoothing_value: Named(float) = None + hatch_border_algorithm: Named(int) = None + hatch_min_hole_area: Named(float) = None + + +@sexp_type('filled_polygon') +class FillPolygon: + layer: Named(str) = "" + pts: PointList = field(default_factory=PointList) + + +@sexp_type('fill_segments') +class FillSegment: + layer: Named(str) = "" + pts: PointList = field(default_factory=PointList) + + +@sexp_type('zone') +class Zone: + net: Named(int) = 0 + net_name: Named(str) = "" + layer: Named(str) = None + layers: Named(Array(str)) = None + tstamp: Timestamp = None + name: Named(str) = None + hatch: Hatch = None + priority: OmitDefault(Named(int)) = 0 + connect_pads: PadConnection = field(default_factory=PadConnection) + min_thickness: Named(float) = 0.254 + filled_areas_thickness: Flag() = True + keepouts: List(ZoneKeepout) = field(default_factory=list) + fill: ZoneFill = field(default_factory=ZoneFill) + polygon: Named(PointList) = field(default_factory=PointList) + fill_polygons: List(FillPolygon) = field(default_factory=list) + fill_segments: List(FillSegment) = field(default_factory=list) + + +@sexp_type('polygon') +class RenderCachePolygon: + pts: PointList = field(default_factory=PointList) + + +@sexp_type('render_cache') +class RenderCache: + text: str = None + rotation: int = 0 + polygons: List(RenderCachePolygon) = field(default_factory=list) + + + diff --git a/src/gerbonara/cad/kicad/sexp.py b/gerbonara/cad/kicad/sexp.py similarity index 99% rename from src/gerbonara/cad/kicad/sexp.py rename to gerbonara/cad/kicad/sexp.py index edc31c2..9312489 100644 --- a/src/gerbonara/cad/kicad/sexp.py +++ b/gerbonara/cad/kicad/sexp.py @@ -64,7 +64,7 @@ term_regex = r"""(?mx) (\))| ([+-]?\d+\.\d+(?=[\s\)]))| (\-?\d+(?=[\s\)]))| - ([^"\s()][^"\s)]*) + ([^0-9"\s()][^"\s)]*) )""" diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py new file mode 100644 index 0000000..cb7c99f --- /dev/null +++ b/gerbonara/cad/kicad/sexp_mapper.py @@ -0,0 +1,289 @@ + +from dataclasses import MISSING +from .sexp import * + + +SEXP_END = type('SEXP_END', (), {}) + + +class AtomChoice: + def __init__(self, *choices): + self.choices = choices + + def __contains__(self, value): + return value in self.choices + + def __atoms__(self): + return self.choices + + def __map__(self, obj, parent=None): + obj, = obj + if obj not in self: + raise TypeError(f'Invalid atom {obj} for {type(self)}, valid choices are: {", ".join(map(str, self.choices))}') + return obj + + def __sexp__(self, value): + yield value + + +class Flag: + def __init__(self, atom=None, invert=None): + self.atom, self.invert = atom, invert + + def __bind_field__(self, field): + if self.atom is None: + self.atom = Atom(field.name) + if self.invert is None: + self.invert = bool(field.default) + + def __atoms__(self): + return [self.atom] + + def __map__(self, obj, parent=None): + return not self.invert + + def __sexp__(self, value): + if bool(value) == (not self.invert): + yield self.atom + + +def sexp(t, v): + if v is None: + return [] + elif t in (int, float, str, Atom): + return [t(v)] + elif hasattr(t, '__sexp__'): + return list(t.__sexp__(v)) + elif isinstance(t, list): + t, = t + return [sexp(t, elem) for elem in v] + else: + raise TypeError(f'Python type {t} has no defined s-expression serialization') + + +def map_sexp(t, v, parent=None): + if t is not Atom and hasattr(t, '__map__'): + return t.__map__(v, parent=parent) + elif t in (int, float, str, Atom): + v, = v + if not isinstance(v, t): + types = set({type(v), t}) + if types == {int, float} or types == {str, Atom}: + v = t(v) + else: + raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}') + return v + elif isinstance(t, list): + t, = t + return [map_sexp(t, elem, parent=parent) for elem in v] + else: + raise TypeError(f'Python type {t} has no defined s-expression deserialization') + + +class WrapperType: + def __init__(self, next_type): + self.next_type = next_type + + def __bind_field__(self, field): + self.field = field + getattr(self.next_type, '__bind_field__', lambda x: None)(field) + + def __atoms__(self): + if hasattr(self, 'name_atom'): + return [self.name_atom] + elif self.next_type is Atom: + return [] + else: + return getattr(self.next_type, '__atoms__', lambda: [])() + +class Named(WrapperType): + def __init__(self, next_type, name=None): + super().__init__(next_type) + self.name_atom = Atom(name) if name else None + + def __bind_field__(self, field): + if self.next_type is not Atom: + getattr(self.next_type, '__bind_field__', lambda x: None)(field) + if self.name_atom is None: + self.name_atom = Atom(field.name) + + def __map__(self, obj, parent=None): + 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) + else: + return map_sexp(self.next_type, obj, parent=parent) + + def __sexp__(self, value): + value = sexp(self.next_type, value) + if value is not None: + yield [self.name_atom, *value] + + +class Rename(WrapperType): + def __init__(self, next_type, name=None): + super().__init__(next_type) + self.name_atom = Atom(name) if name else None + + def __bind_field__(self, field): + if self.name_atom is None: + self.name_atom = Atom(field.name) + + def __map__(self, obj, parent=None): + return map_sexp(self.next_type, obj, parent=parent) + + def __sexp__(self, value): + value, = sexp(self.next_type, value) + if self.next_type in (str, float, int, Atom): + yield [self.name_atom, *value] + else: + key, *rest = value + yield [self.name_atom, *rest] + + +class OmitDefault(WrapperType): + def __bind_field__(self, field): + getattr(self.next_type, '__bind_field__', lambda x: None)(field) + if field.default_factory != MISSING: + self.default = field.default_factory() + else: + self.default = field.default + + def __map__(self, obj, parent=None): + return map_sexp(self.next_type, obj, parent=parent) + + def __sexp__(self, value): + if value != self.default: + yield from sexp(self.next_type, value) + + +class YesNoAtom: + def __init__(self, yes=Atom.yes, no=Atom.no): + self.yes, self.no = yes, no + + def __map__(self, value, parent=None): + value, = value + return value == self.yes + + def __sexp__(self, value): + yield self.yes if value else self.no + + +class Wrap(WrapperType): + def __map__(self, value, parent=None): + value, = value + return map_sexp(self.next_type, value, parent=parent) + + def __sexp__(self, value): + for inner in sexp(self.next_type, value): + yield [inner] + + +class Array(WrapperType): + def __map__(self, value, parent=None): + return [map_sexp(self.next_type, [elem], parent=parent) for elem in value] + + def __sexp__(self, value): + for e in value: + yield from sexp(self.next_type, e) + + +class List(WrapperType): + def __bind_field__(self, field): + self.attr = field.name + + def __map__(self, value, parent): + l = getattr(parent, self.attr, []) + mapped = map_sexp(self.next_type, value, parent=parent) + l.append(mapped) + setattr(parent, self.attr, l) + + def __sexp__(self, value): + for elem in value: + yield from sexp(self.next_type, elem) + + +class _SexpTemplate: + @staticmethod + def __atoms__(kls): + return [kls.name_atom] + + @staticmethod + def __map__(kls, value, parent=None): + positional = iter(kls.positional) + inst = kls() + + 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) + 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) + + else: + try: + pos_key = next(positional) + setattr(inst, pos_key.name, v) + except StopIteration: + raise TypeError(f'Unhandled positional argument {v!r} while parsing {kls}') + + getattr(inst, '__after_parse__', lambda x: None)(parent) + return inst + + @staticmethod + def __sexp__(kls, value): + getattr(value, '__before_sexp__', lambda: None)() + + out = [kls.name_atom] + for f in fields(kls): + if f.type is SEXP_END: + break + out += sexp(f.type, getattr(value, f.name)) + yield out + + @staticmethod + def parse(kls, data): + return kls.__map__(parse_sexp(data)) + + @staticmethod + def sexp(self): + return next(self.__sexp__(self)) + + +def sexp_type(name=None): + def register(cls): + cls = dataclass(cls) + cls.name_atom = Atom(name) if name is not None else None + for key in '__sexp__', '__map__', '__atoms__', 'parse': + if not hasattr(cls, key): + setattr(cls, key, classmethod(getattr(_SexpTemplate, key))) + + if not hasattr(cls, 'sexp'): + setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp')) + cls.positional = [] + cls.keys = {} + for f in fields(cls): + f_type = f.type + if f_type is SEXP_END: + break + + if hasattr(f_type, '__bind_field__'): + f_type.__bind_field__(f) + + atoms = getattr(f_type, '__atoms__', lambda: []) + atoms = list(atoms()) + for atom in atoms: + cls.keys[atom] = (f.name, f_type) + if not atoms: + cls.positional.append(f) + + return cls + return register + + diff --git a/gerbonara/cad/kicad/symbols.py b/gerbonara/cad/kicad/symbols.py new file mode 100644 index 0000000..de1d23d --- /dev/null +++ b/gerbonara/cad/kicad/symbols.py @@ -0,0 +1,446 @@ +""" +Library for processing KiCad's symbol files. +""" + +import json +import string +import math +import re +import sys +import itertools +from fnmatch import fnmatch +from collections import defaultdict +from dataclasses import field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from .sexp import * +from .sexp_mapper import * +from .base_types import * + + +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) + + +PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low, + Atom.output_low, Atom.edge_clock_high, Atom.non_logic) + + +@sexp_type('alternate') +class AltFunction: + name: str = None + etype: PIN_ETYPE = Atom.unspecified + shape: PIN_STYLE = Atom.line + + +@sexp_type('__styled_text') +class StyledText: + value: str = None + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('pin') +class Pin: + etype: PIN_ETYPE = Atom.unspecified + style: PIN_STYLE = Atom.line + at: AtPos = field(default_factory=AtPos) + length: Named(float) = 2.54 + hide: Flag() = False + name: Rename(StyledText) = field(default_factory=StyledText) + number: Rename(StyledText) = field(default_factory=StyledText) + alternates: List(AltFunction) = field(default_factory=list) + + @property + def direction(self): + return {0: 'R', 90: 'U', 180: 'L', 270: 'D'}.get(self.at.rotation, 'R') + + @direction.setter + def direction(self, value): + self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()] + + +@sexp_type('fill') +class Fill: + type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none + + +@sexp_type('circle') +class Circle: + center: Rename(XYCoord) = field(default_factory=XYCoord) + radius: Named(float) = 0.0 + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + +@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) + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + # TODO add function to calculate center, bounding box + + +@sexp_type('polyline') +class Polyline: + pts: PointList = field(default_factory=PointList) + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + @property + def points(self): + return self.pts.xy + + @points.setter + def points(self, value): + self.pts.xy = value + + @property + def closed(self): + # if the last and first point are the same, we consider the polyline closed + # a closed triangle will have 4 points (A-B-C-A) stored in the list of points + return len(self.points) > 3 and self.points[0] == self.points[-1] + + @property + def bbox(self): + if not self.points: + return (0.0, 0.0, 0.0, 0.0) + + return (min(p.x for p in self.points), + min(p.y for p in self.points), + max(p.x for p in self.points), + max(p.y for p in self.points)) + + def as_rectangle(self): + (maxx, maxy, minx, miny) = self.get_boundingbox() + return Rectangle( + minx, + maxy, + maxx, + miny, + self.stroke_width, + self.stroke_color, + self.fill_type, + self.fill_color, + unit=self.unit, + demorgan=self.demorgan, + ) + + def get_center_of_boundingbox(self): + (maxx, maxy, minx, miny) = self.get_boundingbox() + return ((minx + maxx) / 2, ((miny + maxy) / 2)) + + def is_rectangle(self): + # a rectangle has 5 points and is closed + if len(self.points) != 5 or not self.is_closed(): + return False + + # construct lines between the points + p0 = self.points[0] + for p1_idx in range(1, len(self.points)): + p1 = self.points[p1_idx] + dx = p1.x - p0.x + dy = p1.y - p0.y + if dx != 0 and dy != 0: + # if a line is neither horizontal or vertical its not + # part of a rectangle + return False + # select next point + p0 = p1 + + return True + + +@sexp_type('at') +class TextPos(XYCoord): + x: float = 0 # in millimeter + y: float = 0 # in millimeter + rotation: int = 0 # in degrees + + def __after_parse__(self, parent): + self.rotation = self.rotation / 10 + + def __before_sexp__(self): + self.rotation = round((self.rotation % 360) * 10) + + @property + def rotation_rad(self): + return math.radians(self.rotation) + + @rotation_rad.setter + def rotation_rad(self, value): + self.rotation = math.degrees(value) + + +@sexp_type('text') +class Text: + text: str = None + at: TextPos = field(default_factory=TextPos) + rotation: float = None + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('rectangle') +class Rectangle: + """ + Some v6 symbols use rectangles, newer ones encode them as polylines. + At some point in time we can most likely remove this class since its not used anymore + """ + + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + def as_polyline(self): + x1, y1 = self.start + x2, y2 = self.end + return Polyline([Point(x1, y1), Point(x2, y1), Point(x2, y2), Point(x1, y2), Point(x1, y1)], + self.stroke, self.fill) + + +@sexp_type('property') +class Property: + name: str = None + value: str = None + id: Named(int) = None + at: AtPos = field(default_factory=AtPos) + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('pin_numbers') +class PinNumberSpec: + hide: Flag() = False + + +@sexp_type('pin_names') +class PinNameSpec: + offset: OmitDefault(Named(float)) = 0.508 + hide: Flag() = False + + +@sexp_type('symbol') +class Unit: + name: str = None + circles: List(Circle) = field(default_factory=list) + arcs: List(Arc) = field(default_factory=list) + polylines: List(Polyline) = field(default_factory=list) + rectangles: List(Rectangle) = field(default_factory=list) + texts: List(Text) = field(default_factory=list) + pins: List(Pin) = field(default_factory=list) + unit_name: Named(str) = None + _ : SEXP_END = None + global_units: list = field(default_factory=list) + unit_global: Flag() = False + style_global: Flag() = False + demorgan_style: int = 1 + unit_index: int = 1 + symbol = None + + def __after_parse__(self, parent): + self.symbol = parent + + if not (m := re.fullmatch(r'(.*)_([0-9]+)_([0-9]+)', self.name)): + raise FormatError(f'Invalid unit name "{self.name}"') + sym_name, unit_index, demorgan_style = m.groups() + if sym_name != self.symbol.name: + raise FormatError(f'Unit name "{self.name}" does not match symbol name "{self.symbol.name}"') + self.demorgan_style = int(demorgan_style) + self.unit_index = int(unit_index) + self.style_global = self._demorgan_style == 0 + self.unit_global = self.unit_index == 0 + + def __before_sexp__(self): + self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}' + + def __getattr__(self, name): + if name.startswith('all_'): + name = name[4:] + return itertools.chain(getattr(self.global_units, name, []), getattr(self, name, [])) + + def pin_stacks(self): + stacks = defaultdict(lambda: set()) + for pin in self.all_pins(): + stacks[(pin.at.x, pin.at.y)].add(pin) + return stacks + + +@sexp_type('symbol') +class Symbol: + name: str = None + extends: Named(str) = None + power: Wrap(Flag()) = False + pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec) + pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec) + in_bom: Named(YesNoAtom()) = True + on_board: Named(YesNoAtom()) = True + properties: List(Property) = field(default_factory=list) + raw_units: List(Unit) = field(default_factory=list) + _ : SEXP_END = None + styles: {str: {str: Unit}} = None + global_units: {str: {str: Unit}} = None + library = None + + def __after_parse__(self, parent): + self.library = parent + + self.global_units = {} + self.styles = {} + + if self.extends: + self.in_bom = None + self.on_board = None + + self.properties = {prop.name: prop for prop in self.properties} + if (prop := self.properties.get('ki_fp_filters')): + prop.value = prop.value.split() if prop.value else [] + + for unit in self.raw_units: + if unit.unit_global or unit.style_global: + d = self.global_units.get(unit.demorgan_style, {}) + d[unit.name] = unit + self.global_units[unit.demorgan_style] = d + + for other in self.raw_units: + if other.unit_global or other.style_global or other == unit: + continue + if not (unit.unit_global or other.name == unit.name): + continue + if not (unit.style_global or other.demorgan_style == unit.demorgan_style): + continue + other.global_units.append(unit) + + else: + d = self.styles.get(unit.demorgan_style, {}) + d[unit.name] = unit + self.styles[unit.demorgan_style] = d + + def __before_sexp__(self): + self.raw_units = ([unit for style in self.global_units.values() for unit in style.values()] + + [unit for style in self.styles.values() for unit in style.values()]) + if (prop := self.properties.get('ki_fp_filters')): + if not isinstance(prop.value, str): + prop.value = ' '.join(prop.value) + self.properties = list(self.properties.values()) + + def default_properties(self): + for i, (name, value, hide) in enumerate([ + ('Reference', 'U', False), + ('Value', None, False), + ('Footprint', None, True), + ('Datasheet', None, True), + ('ki_locked', None, True), + ('ki_keywords', None, True), + ('ki_description', None, True), + ('ki_fp_filters', None, False), + ]): + self.properties[name] = Property(name=name, value=value, id=i, effects=TextEffect(hide=hide)) + + def units(self, demorgan_style=None): + if self.extends: + return self.library[self.extends].units(demorgan_style) + else: + return self.styles.get(demorgan_style or 'default', {}) + + def get_center_rectangle(self, units): + # return a polyline for the requested unit that is a rectangle + # and is closest to the center + candidates = {} + # building a dict with floats as keys.. there needs to be a rule against that^^ + pl_rects = [i.as_polyline() for i in self.rectangles] + pl_rects.extend(pl for pl in self.polylines if pl.is_rectangle()) + for pl in pl_rects: + if pl.unit in units: + # extract the center, calculate the distance to origin + (x, y) = pl.get_center_of_boundingbox() + dist = math.sqrt(x * x + y * y) + candidates[dist] = pl + + if candidates: + # sort the list return the first (smallest) item + return candidates[sorted(candidates.keys())[0]] + return None + + def is_graphic_symbol(self): + return self.extends is None and ( + not self.pins or self.get_property("Reference").value == "#SYM" + ) + + def pins_by_name(self, demorgan_style=None): + pins = defaultdict(lambda: set()) + for unit in self.units(demorgan_style): + for pin in unit.all_pins: + pins[pin.name].add(pin) + return pins + + def pins_by_number(self, demorgan_style=None): + pins = defaultdict(lambda: set()) + for unit in self.units(demorgan_style): + for pin in unit.all_pins: + pins[pin.number].add(pin) + return pins + + def __getattr__(self, name): + if name.startswith('all_'): + return itertools.chain(getattr(unit, name) for unit in self.raw_units) + + def filter_pins(self, name=None, direction=None, electrical_type=None): + for pin in self.all_pins: + if name and not fnmatch(pin.name, name): + continue + if direction and not pin.direction in direction: + continue + if electrical_type and not pin.etype in electical_type: + continue + yield pin + + def heuristically_small(self): + """ Heuristically try to determine whether this is a "small" component like a resistor, capacitor, LED, diode, + or transistor etc. When we have at most two pins, or there is no filled rectangle as symbol outline and we have + 3 or 4 pins, we assume this is a small symbol. + """ + if len(self.all_pins) <= 2: + return True + if len(self.all_pins) > 4: + return False + return bool(self.get_center_rectangle(range(self.unit_count))) + + +SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914] +@sexp_type('kicad_symbol_lib') +class Library: + _version: Named(int, name='version') = 20211014 + generator: Named(Atom) = Atom.kicad_library_utils + symbols: List(Symbol) = field(default_factory=list) + _ : SEXP_END = None + original_filename: str = None + + @property + def version(self): + return self._version + + @version.setter + def version(self, value): + if value not in SUPPORTED_FILE_FORMAT_VERSIONS: + raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') + + @classmethod + def open(cls, filename: str): + with open(filename) as f: + return cls.parse(f.read()) + + def write(self, filename=None): + with open(filename or self.original_filename, 'w') as f: + f.write(build_sexp(sexp(self))) + + +if __name__ == "__main__": + if len(sys.argv) >= 2: + a = Library.open(sys.argv[1]) + print(build_sexp(sexp(a))) + else: + print("pass a .kicad_sym file please") diff --git a/src/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py similarity index 62% rename from src/gerbonara/cad/primitives.py rename to gerbonara/cad/primitives.py index f581c38..abce1a4 100644 --- a/src/gerbonara/cad/primitives.py +++ b/gerbonara/cad/primitives.py @@ -4,19 +4,16 @@ import math import warnings from copy import copy from itertools import zip_longest, chain -from dataclasses import dataclass, field, replace, KW_ONLY +from dataclasses import dataclass, field, KW_ONLY from collections import defaultdict -from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag, offset_bounds +from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag from ..layers import LayerStack from ..graphic_objects import Line, Arc, Flash -from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAperture, ExcellonTool +from ..apertures import Aperture, CircleAperture, RectangleAperture, ExcellonTool from ..newstroke import Newstroke -class UNDEFINED: - pass - def sgn(x): return -1 if x < 0 else 1 @@ -54,7 +51,7 @@ class Board: @property def abs_pos(self): - return self.x, self.y, self.rotation, False + return self.x, self.y, self.rotation def add_silk(self, side, obj): if side not in ('top', 'bottom'): @@ -80,7 +77,7 @@ class Board: for ko in self.keepouts: if obj.overlaps(ko, unit=MM): if keepout_errors == 'warn': - warnings.warn(f'Object with bounds {obj.bounding_box(MM)} [mm] hits one or more keepout areas') + warnings.warn(msg) elif keepout_errors == 'raise': raise KeepoutError(obj, ko, msg) return @@ -118,11 +115,10 @@ class Board: def layer_stack(self, layer_stack=None): if layer_stack is None: - layer_stack = LayerStack(board_name='proto') + layer_stack = LayerStack() - cache = {} for obj in chain(self.objects): - obj.render(layer_stack, cache) + obj.render(layer_stack) layer_stack['mechanical', 'outline'].objects.extend(self.outline) layer_stack['top', 'silk'].objects.extend(self.extra_silk_top) @@ -145,56 +141,21 @@ class Positioned: y: float _: KW_ONLY rotation: float = 0.0 - flip: bool = False + side: str = 'top' unit: LengthUnit = MM parent: object = None + def flip(self): + self.side = 'top' if self.side == 'bottom' else 'bottom' + @property def abs_pos(self): if self.parent is None: - px, py, pa, pf = 0, 0, 0, False + px, py, pa = 0, 0, 0 else: - px, py, pa, pf = self.parent.abs_pos + px, py, pa = self.parent.abs_pos - return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf)) - - def bounding_box(self, unit=MM): - stack = LayerStack() - self.render(stack) - objects = chain(*(l.objects for l in stack.graphic_layers.values()), - stack.drill_pth.objects, stack.drill_npth.objects) - objects = list(objects) - #print('foo', type(self).__name__, - # [(type(obj).__name__, [prim.bounding_box() for prim in obj.to_primitives(unit)]) for obj in objects], file=sys.stderr) - return sum_bounds(prim.bounding_box() for obj in objects for prim in obj.to_primitives(unit)) - - def overlaps(self, bbox, unit=MM): - return bbox_intersect(self.bounding_box(unit), bbox) - - @property - def single_sided(self): - return True - - -# The dataclass API is slightly idiotic here, so we have to duplicate the entire thing. -@dataclass(frozen=True) -class FrozenPositioned: - x: float - y: float - _: KW_ONLY - rotation: float = 0.0 - flip: bool = False - unit: LengthUnit = MM - parent: object = None - - @property - def abs_pos(self): - if self.parent is None: - px, py, pa, pf = 0, 0, 0, False - else: - px, py, pa, pf = self.parent.abs_pos - - return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf)) + return self.x+px, self.y+py, self.rotation+pa def bounding_box(self, unit=MM): stack = LayerStack() @@ -215,7 +176,7 @@ class FrozenPositioned: @dataclass -class Graphics(Positioned): +class ObjectGroup(Positioned): top_copper: list = field(default_factory=list) top_mask: list = field(default_factory=list) top_silk: list = field(default_factory=list) @@ -226,10 +187,15 @@ class Graphics(Positioned): bottom_paste: list = field(default_factory=list) drill_npth: list = field(default_factory=list) drill_pth: list = field(default_factory=list) + objects: list = field(default_factory=list) - def render(self, layer_stack, cache=None): - x, y, rotation, flip = self.abs_pos - top, bottom = ('bottom', 'top') if flip else ('top', 'bottom') + def render(self, layer_stack): + x, y, rotation = self.abs_pos + top, bottom = ('bottom', 'top') if self.side == 'bottom' else ('top', 'bottom') + + for obj in self.objects: + obj.parent = self + obj.render(layer_stack) for target, source in [ (layer_stack[top, 'copper'], self.top_copper), @@ -249,23 +215,6 @@ class Graphics(Positioned): fe.offset(x, y, self.unit) target.objects.append(fe) - def bounding_box(self, unit=MM): - if math.isclose(self.rotation, 0, abs_tol=1e-3): - return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in chain( - self.top_copper, - self.top_mask, - self.top_silk, - self.top_paste, - self.bottom_copper, - self.bottom_mask, - self.bottom_silk, - self.bottom_paste, - self.drill_npth, - self.drill_pth, - ))), unit(self.x, self.unit), unit(self.y, self.unit)) - else: - return super().bounding_box(unit) - @property def single_sided(self): any_top = self.top_copper or self.top_mask or self.top_paste or self.top_silk @@ -274,30 +223,6 @@ class Graphics(Positioned): return not (any_drill or (any_top and any_bottom)) -@dataclass -class ObjectGroup(Positioned): - objects: list = field(default_factory=list) - - def render(self, layer_stack, cache=None): - for obj in self.objects: - if not isinstance(obj, Positioned): - raise ValueError(f'ObjectGroup members must be children of Positioned, not {type(obj)}') - - obj.parent = self - obj.render(layer_stack, cache=cache) - - def bounding_box(self, unit=MM): - if math.isclose(self.rotation, 0, abs_tol=1e-3): - return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in self.objects)), - unit(self.x, self.unit), unit(self.y, self.unit)) - else: - return super().bounding_box(unit) - - @property - def single_sided(self): - return all(obj.single_sided for obj in self.objects) - - @dataclass class Text(Positioned): text: str @@ -308,8 +233,8 @@ class Text(Positioned): layer: str = 'silk' polarity_dark: bool = True - def render(self, layer_stack, cache=None): - obj_x, obj_y, rotation, flip = self.abs_pos + def render(self, layer_stack): + obj_x, obj_y, rotation = self.abs_pos global newstroke_font if newstroke_font is None: @@ -322,7 +247,6 @@ 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 @@ -333,16 +257,16 @@ class Text(Positioned): else: raise ValueError('h_align must be one of "left", "center", or "right".') - if self.v_align == 'bottom': - y0 = h + if self.v_align == 'top': + y0 = -(max_y - min_y) elif self.v_align == 'middle': - y0 = h/2 - elif self.v_align == 'top': + y0 = -(max_y - min_y)/2 + elif self.v_align == 'bottom': y0 = 0 else: raise ValueError('v_align must be one of "top", "middle", or "bottom".') - if self.flip: + if self.side == 'bottom': x0 += min_x + max_x x_sign = -1 else: @@ -352,225 +276,162 @@ 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) - - def bounding_box(self, unit=MM): - approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width - approx_h = self.font_size + self.stroke_width - - if self.h_align == 'left': - x0 = 0 - elif self.h_align == 'center': - x0 = -approx_w/2 - elif self.h_align == 'right': - x0 = -approx_w - - if self.v_align == 'top': - y0 = 0 - elif self.v_align == 'middle': - y0 = -approx_h/2 - elif self.v_align == 'bottom': - y0 = -approx_h - - return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h) - - -@dataclass(frozen=True, slots=True) -class PadStackAperture: - aperture: Aperture - side: str - layer: str - offset_x: float = 0 # in PadStack units - offset_y: float = 0 - rotation: float = 0 - invert: bool = False - - -@dataclass(frozen=True, slots=True) -class PadStack: - _: KW_ONLY - unit: LengthUnit = MM - - @property - def apertures(self): - raise NotImplementedError() - - 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, aperture, polarity_dark=not ap.invert, unit=self.unit) - fl.rotate(rotation) - fl.offset(x, y) - side = ap.side - if flip: - side = {'top': 'bottom', 'bottom': 'top'}.get(side, side) - 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 layer == 'plated': - layer_stack.drill_pth.objects.append(flash) - - elif side == 'drill' and layer == 'nonplated': - layer_stack.drill_npth.objects.append(flash) - - elif (side, layer) in layer_stack: - layer_stack[side, layer].objects.append(flash) - - @property - def single_sided(self): - return len({ap.side for ap in self.apertures}) <= 1 - - -@dataclass(frozen=True, slots=True) -class SMDStack(PadStack): - aperture: Aperture - mask_expansion: float = 0.0 - paste_expansion: float = 0.0 - paste: bool = True - flip: bool = False - - @property - def side(self): - return 'bottom' if self.flip else 'top' - - @property - def apertures(self): - yield PadStackAperture(self.aperture, self.side, 'copper') - yield PadStackAperture(self.aperture.dilated(self.mask_expansion, self.unit), self.side, 'mask') - if self.paste: - yield PadStackAperture(self.aperture.dilated(self.paste_expansion, self.unit), self.side, 'paste') - - @classmethod - def rect(kls, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM): - ap = RectangleAperture(w, h, unit=unit).rotated(rotation) - return kls(ap, mask_expansion, paste_expansion, paste, flip, unit=unit) - - @classmethod - def circle(kls, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM): - 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 = 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') - - @property - def plating(self): - return 'plated' if self.plated else 'nonplated' - - @property - def apertures(self): - yield from self.pad_top.apertures - yield from self.pad_bottom.apertures - 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 - def single_sided(self): - return False - - @classmethod - def rect(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): - pad = SMDStack.rect(w, h, rotation, mask_expansion, paste_expansion, paste, unit=unit) - return kls(drill_dia, pad, plated=plated) - - @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, mask_expansion, paste_expansion, paste, unit=unit) - return kls(drill_dia, pad, plated=plated) - - @classmethod - def obround(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): - ap = ObroundAperture(w, h, unit=unit).rotated(rotation) - pad = SMDStack(ap, mask_expansion, paste_expansion, paste, unit=unit) - return kls(drill_dia, pad, plated=plated) - -@dataclass(frozen=True, slots=True) -class ThroughViaStack(PadStack): - hole: float - dia: float = None - tented: bool = True - - def __post_init__(self): - if self.dia == None: - object.__setattr__(self, 'dia', self.hole*2) - - @property - def single_sided(self): - return False - - @property - def apertures(self): - copper_aperture = CircleAperture(self.dia, unit=self.unit) - yield PadStackAperture(copper_aperture, 'top', 'copper') - yield PadStackAperture(copper_aperture, 'bottom', 'copper') - yield PadStackAperture(copper_aperture, 'inner', 'copper') - if self.tented: - yield PadStackAperture(copper_aperture, 'top', 'mask') - yield PadStackAperture(copper_aperture, 'bottom', 'mask') - yield PadStackAperture(ExcellonTool(self.hole, plated=True, unit=self.unit), 'drill', 'plated') - - -@dataclass(frozen=True, slots=True) -class Via(FrozenPositioned): - 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) - - @classmethod - def at(kls, x, y, hole, dia=None, tented=True, unit=MM): - return kls(x, y, ThroughViaStack(hole, dia, tented, unit=unit), unit=unit) + layer_stack[self.side, self.layer].objects.append(obj) @dataclass class Pad(Positioned): - pad_stack: PadStack + pass - def render(self, layer_stack, cache=None): - x, y, rotation, flip = self.abs_pos - self.pad_stack.render(layer_stack, x, y, rotation, flip) + +@dataclass +class SMDPad(Pad): + copper_aperture: Aperture + mask_aperture: Aperture + paste_aperture: Aperture + silk_features: list = field(default_factory=list) + + def render(self, layer_stack): + x, y, rotation = self.abs_pos + layer_stack[self.side, 'copper'].objects.append(Flash(x, y, self.copper_aperture.rotated(rotation), unit=self.unit)) + layer_stack[self.side, 'mask' ].objects.append(Flash(x, y, self.mask_aperture.rotated(rotation), unit=self.unit)) + if self.paste_aperture: + layer_stack[self.side, 'paste' ].objects.append(Flash(x, y, self.paste_aperture.rotated(rotation), unit=self.unit)) + layer_stack[self.side, 'silk' ].objects.extend([copy(feature).rotate(rotation).offset(x, y, self.unit) + for feature in self.silk_features]) + + @classmethod + def rect(kls, x, y, w, h, rotation=0, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM): + ap_c = RectangleAperture(w, h, unit=unit) + ap_m = RectangleAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit) + ap_p = RectangleAperture(w+2*paste_expansion, h+2*paste_expansion, unit=unit) if paste else None + return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, rotation=rotation, + unit=unit) + + @classmethod + def circle(kls, x, y, dia, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM): + ap_c = CircleAperture(dia, unit=unit) + ap_m = CircleAperture(dia+2*mask_expansion, unit=unit) + ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None + return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit) + + +@dataclass +class THTPad(Pad): + drill_dia: float + pad_top: SMDPad + pad_bottom: SMDPad = None + aperture_inner: Aperture = None + plated: bool = True + + def __post_init__(self): + if self.pad_bottom is None: + import sys + self.pad_bottom = copy(self.pad_top) + self.pad_bottom.flip() + + self.pad_top.parent = self.pad_bottom.parent = self + + if (self.pad_top.side, self.pad_bottom.side) != ('top', 'bottom'): + raise ValueError(f'The top and bottom pads must have side set to top and bottom, respectively. Currently, the top pad side is set to "{self.pad_top.side}" and the bottom pad side to "{self.pad_bottom.side}".') + + def render(self, layer_stack): + x, y, rotation = self.abs_pos + self.pad_top.parent = self + self.pad_top.render(layer_stack) + if self.pad_bottom: + self.pad_bottom.parent = self + self.pad_bottom.render(layer_stack) + + if self.aperture_inner is None: + (x_min, y_min), (x_max, y_max) = self.pad_top.bounding_box(MM) + w_top = x_max - x_min + h_top = y_max - y_min + if self.pad_bottom: + (x_min, y_min), (x_max, y_max) = self.pad_bottom.bounding_box(MM) + w_bottom = x_max - x_min + h_bottom = y_max - y_min + w_top = min(w_top, w_bottom) + h_top = min(h_top, h_bottom) + self.aperture_inner = CircleAperture(min(w_top, h_top), unit=MM) + + for (side, use), layer in layer_stack.inner_layers: + layer.objects.append(Flash(x, y, self.aperture_inner.rotated(rotation), unit=self.unit)) + + hole = Flash(x, y, ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), unit=self.unit) + if self.plated: + layer_stack.drill_pth.objects.append(hole) + else: + layer_stack.drill_npth.objects.append(hole) @property def single_sided(self): - return self.pad_stack.single_sided + return False + + @classmethod + def rect(kls, x, y, hole_dia, w, h=None, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): + if h is None: + h = w + pad = SMDPad.rect(0, 0, w, h, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit) + return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit) + + @classmethod + def circle(kls, x, y, hole_dia, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM): + pad = SMDPad.circle(0, 0, dia, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit) + return kls(x, y, hole_dia, pad, plated=plated, unit=unit) + + @classmethod + def obround(kls, x, y, hole_dia, w, h, rotation=0, mask_expansion=0.0, paste_expanson=0.0, paste=True, plated=True, unit=MM): + ap_c = CircleAperture(dia, unit=unit) + ap_m = CircleAperture(dia+2*mask_expansion, unit=unit) + ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None + pad = SMDPad(0, 0, side='top', copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit) + return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit) + + +@dataclass +class Hole(Positioned): + diameter: float + mask_copper_margin: float = 0.2 + + def render(self, layer_stack): + x, y, rotation = self.abs_pos + + hole = Flash(x, y, ExcellonTool(self.diameter, plated=False, unit=self.unit), unit=self.unit) + layer_stack.drill_npth.objects.append(hole) + + if self.mask_copper_margin > 0: + mask = Flash(x, y, CircleAperture(self.mask_copper_margin, unit=self.unit), polarity_dark=False, unit=self.unit) + layer_stack['top', 'copper'].objects.append(mask) + layer_stack['bottom', 'copper'].objects.append(mask) + + @property + def single_sided(self): + return False + + +@dataclass +class Via(Positioned): + diameter: float + hole: float + + def render(self, layer_stack): + x, y, rotation = self.abs_pos + + aperture = CircleAperture(diameter=self.diameter, unit=self.unit) + tool = ExcellonTool(diameter=self.hole, unit=self.unit) + + for (side, use), layer in layer_stack.copper_layers: + layer.objects.append(Flash(x, y, aperture, unit=self.unit)) + + layer_stack.drill_pth.objects.append(Flash(x, y, tool, unit=self.unit)) + + @property + def single_sided(self): + return False @dataclass @@ -580,9 +441,8 @@ class Trace: end: object = None waypoints: [(float, float)] = field(default_factory=list) style: str = 'oblique' - orientation: [str] = tuple() # 'cw' or 'ccw' + orientation: [str] = tuple() # 'top' or 'bottom' roundover: float = 0 - side: str = 'top' unit: LengthUnit = MM parent: object = None @@ -730,13 +590,13 @@ class Trace: yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit) - def to_graphic_objects(self): + def _to_graphic_objects(self): start, end = self.start, self.end if not isinstance(start, tuple): - *start, _rotation, _flip = start.abs_pos + *start, _rotation = start.abs_pos if not isinstance(end, tuple): - *end, _rotation, _flip = end.abs_pos + *end, _rotation = end.abs_pos aperture = CircleAperture(diameter=self.width, unit=self.unit) @@ -749,8 +609,8 @@ class Trace: return self._round_over(points, aperture) - def render(self, layer_stack, cache=None): - layer_stack[self.side, 'copper'].objects.extend(self.to_graphic_objects()) + def render(self, layer_stack): + layer_stack[self.side, 'copper'].objects.extend(self._to_graphic_objects()) def _route_demo(): from ..utils import setup_svg, Tag diff --git a/gerbonara/cad/protoboard.py b/gerbonara/cad/protoboard.py new file mode 100644 index 0000000..b61a0ed --- /dev/null +++ b/gerbonara/cad/protoboard.py @@ -0,0 +1,525 @@ + +import sys +import re +import math +import string +import itertools +from copy import copy, deepcopy +import warnings + +from .primitives import * +from ..graphic_objects import Region +from ..apertures import RectangleAperture, CircleAperture + + +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) + else: + return max(widths), 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, unit=MM): + self.pitch_x = pitch_x + self.pitch_y = pitch_y or pitch_x + 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), (w, h))) + return max_x-min_x, max_y-min_y + + def fit_rect(self, bbox, unit=MM): + (x, y), (w, h) = bbox + w, h = w-x, h-y + + 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, rotation=math.pi/4, unit=unit) + large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, rotation=math.pi/4, unit=unit) + + 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, rotation=math.pi/4, unit=unit) + 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): + 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))) + + +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)) + 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) + stack = PropLayout([pattern2, pattern3], 'v', [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, obj=PoweredProto()) + #pattern = PatternProtoArea(2.54, obj=RFGroundProto()) + #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()) + pb = ProtoBoard(100, 80, pattern, mounting_hole_dia=3.2, mounting_hole_offset=5) + 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) + diff --git a/src/gerbonara/cad/protoserve.py b/gerbonara/cad/protoserve.py similarity index 57% rename from src/gerbonara/cad/protoserve.py rename to gerbonara/cad/protoserve.py index 994bc20..a210e91 100644 --- a/src/gerbonara/cad/protoserve.py +++ b/gerbonara/cad/protoserve.py @@ -4,11 +4,10 @@ import importlib.resources from tempfile import NamedTemporaryFile, TemporaryDirectory from pathlib import Path -from quart import Quart, request, Response, send_file, abort +from quart import Quart, request, Response, send_file from . import protoboard as pb from . import protoserve_data -from .primitives import SMDStack from ..utils import MM, Inch @@ -26,7 +25,7 @@ def extract_importlib(package): else: assert item.is_dir() item_out.mkdir() - stack.append((item, item_out)) + stack.push((item, item_out)) return root @@ -63,10 +62,10 @@ def deserialize(obj, unit): case 'smd': match obj['pad_shape']: case 'rect': - stack = SMDStack.rect(pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit) + pad = pb.SMDPad.rect(0, 0, pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit) case 'circle': - stack = SMDStack.circle(min(pitch_x, pitch_y)-clearance, paste=False, unit=unit) - return pb.PatternProtoArea(pitch_x, pitch_y, obj=stack, unit=unit) + 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) case 'tht': hole_dia = mil(float(obj['hole_dia'])) @@ -80,11 +79,11 @@ def deserialize(obj, unit): match obj['pad_shape']: case 'rect': - pad = pb.THTPad.rect(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit) + pad = pb.THTPad.rect(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit) case 'circle': - pad = pb.THTPad.circle(hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit) + pad = pb.THTPad.circle(0, 0, hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit) case 'obround': - pad = pb.THTPad.obround(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit) + pad = pb.THTPad.obround(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit) if oneside: pad.pad_bottom = None @@ -98,50 +97,14 @@ def deserialize(obj, unit): pitch = mil(float(obj.get('pitch', 2.54))) hole_dia = mil(float(obj['hole_dia'])) via_drill = mil(float(obj['via_hole_dia'])) - via_dia = mil(float(obj['via_dia'])) trace_width = mil(float(obj['trace_width'])) - # Force 1mm margin to avoid shorts when adjacent to planes such as that one in the RF THT proto. - return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, power_pad_dia=via_dia, trace_width=trace_width, unit=unit), margin=unit(1.0, MM), unit=unit) + return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, trace_width=trace_width, unit=unit), unit=unit) case 'flower': pitch = mil(float(obj.get('pitch', 2.54))) hole_dia = mil(float(obj['hole_dia'])) pattern_dia = mil(float(obj['pattern_dia'])) - 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) - - case 'alio': - pitch = mil(float(obj.get('pitch', 2.54))) - drill = mil(float(obj.get('hole_dia', 0.9))) - clearance = mil(float(obj.get('clearance', 0.3))) - link_pad_width = mil(float(obj.get('link_pad_width', 1.1))) - link_trace_width = mil(float(obj.get('link_trace_width', 0.5))) - via_size = mil(float(obj.get('via_hole_dia', 0.4))) - return pb.PatternProtoArea(pitch, pitch, pb.AlioCell( - pitch=pitch, - drill=drill, - clearance=clearance, - link_pad_width=link_pad_width, - link_trace_width=link_trace_width, - 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) + return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit) case 'rf': pitch = float(obj.get('pitch', 2.54)) @@ -155,16 +118,12 @@ 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))) if obj.get('children'): - try: - content = deserialize(obj['children'][0], unit) - except ValueError: - return abort(400) + content = deserialize(obj['children'][0], unit) else: content = [pb.EmptyProtoArea()] @@ -172,14 +131,13 @@ 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(side): +@app.route('/preview.svg', methods=['POST']) +async def preview(): obj = await request.get_json() board = to_board(obj) - return Response(str(board.pretty_svg(side=side)), mimetype='image/svg+xml') + return Response(str(board.pretty_svg()), mimetype='image/svg+xml') @app.route('/gerbers.zip', methods=['POST']) async def gerbers(): @@ -190,10 +148,7 @@ async def gerbers(): board.layer_stack().save_to_zipfile(f) return Response(f.read_bytes(), mimetype='image/svg+xml') -def main(): - app.run() - if __name__ == '__main__': - main() + app.run() diff --git a/src/gerbonara/cad/protoserve_data/protoserve.html b/gerbonara/cad/protoserve_data/protoserve.html similarity index 67% rename from src/gerbonara/cad/protoserve_data/protoserve.html rename to gerbonara/cad/protoserve_data/protoserve.html index 4da027a..3afeb5c 100644 --- a/src/gerbonara/cad/protoserve_data/protoserve.html +++ b/gerbonara/cad/protoserve_data/protoserve.html @@ -97,11 +97,6 @@ input { text-align: center; } -.group > .attribution, .group > .usage { - grid-column-start: 1; - grid-column-end: span 3; -} - .group > div > .proportion { display: none; } @@ -145,14 +140,6 @@ input { margin: 0 5px 0 5px; } -input[type="text"]:invalid { - background: rgba(255 0 0 / 30%); -} - -input[type="text"]:focus:valid { - background: rgba(0 192 64 / 30%); -} - .group.expand { border-radius: 0; } @@ -174,39 +161,16 @@ input[type="text"]:focus:valid { } #preview { - position: relative; grid-area: main; padding: 20px; - display: flex; - flex-direction: column; - flex-wrap: wrap; - align-items: stretch; } -#preview > img { - flex-grow: 1; +#preview-image { + width: 100%; + height: 100%; object-fit: contain; } -#preview-message { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: hsla(0 0% 50% / 30%); - display: none; - justify-content: center; - align-items: center; - font-size: 18pt; - font-weight: bold; - color: white; -} - -#preview-message.loading { - display: flex; -} - #links { grid-area: links; display: flex; @@ -261,73 +225,63 @@ input[type="text"]:focus:valid {
-
-
-

Board settings

-
- Automatically generated top side preview image - Automatically generated bottom side preview image -
+ Automatically generated preview image
@@ -422,22 +372,22 @@ input[type="text"]:focus:valid {

SMD area

(RemoveMove)
Area Settings
@@ -445,10 +395,11 @@ input[type="text"]:focus:valid { @@ -460,34 +411,34 @@ input[type="text"]:focus:valid {

THT area

(RemoveMove)
Area Settings
@@ -499,35 +450,7 @@ input[type="text"]:focus:valid { -
- - - - - - - - -