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/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 { -
- - - - - - - - -