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..1cb51ee 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,54 +14,53 @@ 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: + - 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: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 + - 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/* docs:archlinux: stage: test @@ -84,7 +83,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' + +from dataclasses import dataclass, field, replace +import operator +import re +import ast +import copy +import math + +from . import primitive as ap +from .expression import * +from ..utils import MM + +# we make our own here instead of using math.degrees to make sure this works with expressions, too. +def rad_to_deg(x): + return (x / math.pi) * 180 + +def _map_expression(node): + 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) + +@dataclass(frozen=True, slots=True) +class ApertureMacro: + name: str = field(default=None, hash=False, compare=False) + primitives: tuple = () + variables: tuple = () + comments: tuple = field(default=(), hash=False, compare=False) + + def __post_init__(self): + if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name): + # We can't use field(default_factory=...) here because that factory doesn't get a reference to the instance. + self._reset_name() + + def _reset_name(self): + object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}') + + @classmethod + def parse_macro(kls, macro_name, body, unit): + comments = [] + variables = {} + primitives = [] + + blocks = body.split('*') + for block in blocks: + if not (block := block.strip()): # empty block + continue + + if block.startswith('0 '): # comment + comments.append(block[2:]) + continue + + block = re.sub(r'\s', '', block) + + if block[0] == '$': # variable definition + name, expr = block.partition('=') + number = int(name[1:]) + if number in variables: + raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro') + variables[number] = _parse_expression(expr) + + else: # primitive + primitive, *args = block.split(',') + args = [ _parse_expression(arg) for arg in args ] + primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args)) + + variables = [variables.get(i+1) for i in range(max(variables.keys(), default=0))] + return kls(macro_name, tuple(primitives), tuple(variables), tuple(comments)) + + def __str__(self): + return f'' + + def __repr__(self): + return str(self) + + def dilated(self, offset, unit=MM): + new_primitives = [] + for primitive in self.primitives: + try: + if primitive.exposure.calculate(): + new_primitives += primitive.dilated(offset, unit) + except IndexError: + warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.') + pass + return replace(self, primitives=tuple(new_primitives)) + + def to_gerber(self, unit=None): + """ Serialize this macro's content (without the name) into Gerber using the given file unit """ + comments = [ str(c) for c in self.comments ] + variable_defs = [ f'${var}={str(expr)[1:-1]}' for var, expr in enumerate(self.variables, start=1) if expr is not None ] + 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 = {i: v for i, v in enumerate(self.variables, start=1) if v is not None} + for number, value in enumerate(parameters, start=1): + if number in variables: + raise SyntaxError(f'Re-definition of aperture macro variable {number} 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): + # aperture macro primitives use degree counter-clockwise, our API uses radians clockwise + return replace(self, primitives=tuple( + replace(primitive, rotation=primitive.rotation - rad_to_deg(angle)) for primitive in self.primitives)) + + def scaled(self, scale): + return replace(self, primitives=tuple( + primitive.scaled(scale) for primitive in self.primitives)) + + +var = VariableExpression +deg_per_rad = 180 / math.pi + +class GenericMacros: + + _generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),) + + # NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing + # API. + circle = ApertureMacro('GNC', ( + 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)), var(6) * -deg_per_rad), + ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad), + ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), + ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad), + *_generic_hole(4))) + + # params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation + isosceles_trapezoid = ApertureMacro('GTR', ( + ap.Outline('mm', 1, 4, + (var(1)/-2, var(2)/-2, + var(1)/-2+var(3)/2, var(2)/2, + var(1)/2-var(3)/2, var(2)/2, + var(1)/2, var(2)/-2, + var(1)/-2, var(2)/-2,), + var(6) * -deg_per_rad), + *_generic_hole(4))) + + # params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation + rounded_isosceles_trapezoid = ApertureMacro('GRTR', ( + ap.Outline('mm', 1, 4, + (var(1)/-2, var(2)/-2, + var(1)/-2+var(3)/2, var(2)/2, + var(1)/2-var(3)/2, var(2)/2, + var(1)/2, var(2)/-2, + var(1)/-2, var(2)/-2,), + var(6) * -deg_per_rad), + ap.VectorLine('mm', 1, var(4)*2, + var(1)/-2, var(2)/-2, + var(1)/-2+var(3)/2, var(2)/2,), + ap.VectorLine('mm', 1, var(4)*2, + var(1)/-2+var(3)/2, var(2)/2, + var(1)/2-var(3)/2, var(2)/2,), + ap.VectorLine('mm', 1, var(4)*2, + var(1)/2-var(3)/2, var(2)/2, + var(1)/2, var(2)/-2,), + ap.VectorLine('mm', 1, var(4)*2, + var(1)/2, var(2)/-2, + var(1)/-2, var(2)/-2,), + ap.Circle('mm', 1, var(4)*2, + var(1)/-2, var(2)/-2,), + ap.Circle('mm', 1, var(4)*2, + var(1)/-2+var(3)/2, var(2)/2,), + ap.Circle('mm', 1, var(4)*2, + var(1)/2-var(3)/2, var(2)/2,), + ap.Circle('mm', 1, var(4)*2, + var(1)/2, var(2)/-2,), + *_generic_hole(5))) + + # w must be larger than h + # params: width, height, *hole, rotation + obround = ApertureMacro('GNO', ( + ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad), + ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), + ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), + *_generic_hole(3) )) + + polygon = ApertureMacro('GNP', ( + 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/src/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py similarity index 65% rename from src/gerbonara/aperture_macros/primitive.py rename to gerbonara/aperture_macros/primitive.py index d31aef8..f575b0c 100644 --- a/src/gerbonara/aperture_macros/primitive.py +++ b/gerbonara/aperture_macros/primitive.py @@ -7,7 +7,7 @@ import warnings import contextlib import math -from dataclasses import dataclass, fields, replace +from dataclasses import dataclass, fields from .expression import Expression, UnitExpression, ConstantExpression, expr @@ -34,6 +34,7 @@ def rad_to_deg(a): @dataclass(frozen=True, slots=True) class Primitive: unit: LengthUnit + exposure : Expression def __post_init__(self): for field in fields(self): @@ -45,16 +46,9 @@ class Primitive: elif field.type == Expression: object.__setattr__(self, field.name, expr(getattr(self, field.name))) - def to_gerber(self, register_variable=None, settings=None): + def to_gerber(self, unit=None): return f'{self.code},' + ','.join( - getattr(self, field.name).optimized().to_gerber(register_variable, settings.unit) - for field in fields(self) if issubclass(field.type, Expression)) - - def substitute_params(self, binding, unit): - out = replace(self, unit=unit, **{ - field.name: getattr(self, field.name).calculate(binding, unit) - for field in fields(self) if issubclass(field.type, Expression)}) - return out + getattr(self, field.name).to_gerber(unit) for field in fields(self) if field.name != 'unit') def __str__(self): attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) @@ -67,11 +61,6 @@ class Primitive: def from_arglist(kls, unit, arglist): return kls(unit, *arglist) - def parameters(self): - for field in fields(self): - if issubclass(field.type, Expression): - yield from getattr(self, field.name).parameters() - class Calculator: def __init__(self, instance, variable_binding={}, unit=None): self.instance = instance @@ -94,29 +83,18 @@ class Primitive: @dataclass(frozen=True, slots=True) class Circle(Primitive): code = 1 - exposure : Expression diameter : UnitExpression # center x/y - x : UnitExpression = 0 - y : UnitExpression = 0 + x : UnitExpression + y : UnitExpression rotation : Expression = 0 def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): with self.Calculator(self, variable_binding, unit) as calc: x, y = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0) x, y = x+offset[0], y+offset[1] - - if math.isclose(calc.diameter, 0): - return [] - return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] - def substitute_params(self, binding, unit): - with self.Calculator(self, binding, unit) as calc: - x, y = rotate_point(calc.x, calc.y, -deg_to_rad(calc.rotation), 0, 0) - new = Circle(unit, self.exposure, calc.diameter, x, y) - return new - def dilated(self, offset, unit): return replace(self, diameter=self.diameter + UnitExpression(offset, unit)) @@ -128,7 +106,6 @@ class Circle(Primitive): @dataclass(frozen=True, slots=True) class VectorLine(Primitive): code = 20 - exposure : Expression width : UnitExpression start_x : UnitExpression start_y : UnitExpression @@ -148,18 +125,9 @@ class VectorLine(Primitive): center_x, center_y = center_x+offset[0], center_y+offset[1] rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x) - if math.isclose(calc.width, 0): - return [] - return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] - def substitute_params(self, binding, unit): - with self.Calculator(self, binding, unit) as calc: - x1, y1 = rotate_point(calc.start_x, calc.start_y, -deg_to_rad(calc.rotation), 0, 0) - x2, y2 = rotate_point(calc.end_x, calc.end_y, -deg_to_rad(calc.rotation), 0, 0) - return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2) - def dilated(self, offset, unit): return replace(self, width=self.width + UnitExpression(2*offset, unit)) @@ -174,7 +142,6 @@ class VectorLine(Primitive): @dataclass(frozen=True, slots=True) class CenterLine(Primitive): code = 21 - exposure : Expression width : UnitExpression height : UnitExpression # center x/y @@ -189,17 +156,8 @@ class CenterLine(Primitive): x, y = x+offset[0], y+offset[1] w, h = calc.width, calc.height - if math.isclose(calc.width, 0) or math.isclose(calc.height, 0): - return [] - return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] - def substitute_params(self, binding, unit): - with self.Calculator(self, binding, unit) as calc: - x1, y1 = rotate_point(calc.x, calc.y-calc.height/2, -deg_to_rad(calc.rotation), 0, 0) - x2, y2 = rotate_point(calc.x, calc.y+calc.height/2, -deg_to_rad(calc.rotation), 0, 0) - return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2) - def dilated(self, offset, unit): return replace(self, width=self.width + UnitExpression(2*offset, unit)) @@ -214,7 +172,6 @@ class CenterLine(Primitive): @dataclass(frozen=True, slots=True) class Polygon(Primitive): code = 5 - exposure : Expression n_vertices : Expression # center x/y x : UnitExpression @@ -227,8 +184,7 @@ class Polygon(Primitive): rotation += deg_to_rad(calc.rotation) x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0) x, y = x+offset[0], y+offset[1] - print('xy', calc.x, calc.y) - return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation, + 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 dilated(self, offset, unit): @@ -241,60 +197,9 @@ class Polygon(Primitive): y=self.y * UnitExpression(scale)) -@dataclass(frozen=True, slots=True) -class Moire(Primitive): - """ Deprecated, but still found in some really old gerber files. """ - code = 6 - # center x/y - x : UnitExpression - y : UnitExpression - d_outer : UnitExpression - line_thickness : UnitExpression - gap_w : UnitExpression - num_circles : Expression - crosshair_thickness : UnitExpression = 0 - crosshair_length : UnitExpression =0 - rotation : Expression = 0 - - def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): - with self.Calculator(self, variable_binding, unit) as calc: - rotation += deg_to_rad(calc.rotation) - x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0) - x, y = x+offset[0], y+offset[1] - - if math.isclose(calc.d_outer, 0): - return [] - - pitch = calc.line_thickness + calc.gap_w - for i in range(int(round(calc.num_circles))): - yield gp.Circle(x, y, calc.d_outer/2 - i*pitch, polarity_dark=True) - yield gp.Circle(x, y, calc.d_inner/2 - i*pitch - calc.line_thickness, polarity_dark=False) - - if math.isclose(calc.crosshair_thickness, 0, abs_tol=1e-6) or\ - math.isclose(calc.crosshair_length, 0, abs_tol=1e-6): - return - - yield gp.Rectangle(x, y, crosshair_length, crosshair_thickness, rotation=rotation, polarity_dark=True) - yield gp.Rectangle(x, y, crosshair_thickness, crosshair_length, rotation=rotation, polarity_dark=True) - - 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): - return replace(self, - d_outer=self.d_outer * UnitExpression(scale), - d_inner=self.d_inner * UnitExpression(scale), - gap_w=self.gap_w * UnitExpression(scale), - x=self.x * UnitExpression(scale), - y=self.y * UnitExpression(scale)) - - @dataclass(frozen=True, slots=True) class Thermal(Primitive): code = 7 - # Note: Thermal primitives according to spec don't have an exposure variable # center x/y x : UnitExpression y : UnitExpression @@ -309,16 +214,13 @@ class Thermal(Primitive): x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0) x, y = x+offset[0], y+offset[1] - dark = True - - if math.isclose(calc.d_outer, 0): - return [] + 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, calc.d_outer, calc.gap_w, rotation=rotation, polarity_dark=not dark), - gp.Rectangle(x, y, calc.gap_w, calc.d_outer, rotation=rotation, polarity_dark=not dark), + 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): @@ -338,7 +240,6 @@ class Thermal(Primitive): @dataclass(frozen=True, slots=True) class Outline(Primitive): code = 4 - exposure : Expression length: Expression coords: tuple rotation: Expression = 0 @@ -375,24 +276,21 @@ class Outline(Primitive): def __str__(self): return f'' - def to_gerber(self, register_variable=None, settings=None): + def to_gerber(self, unit=None): + # Calculate out rotation since at least gerbv mis-renders Outlines with rotation other than zero. rotation = self.rotation.optimized() - coords = ','.join(coord.optimized().to_gerber(register_variable, settings.unit) for coord in self.coords) - return f'{self.code},{self.exposure.optimized().to_gerber(register_variable)},{len(self.coords)//2-1},{coords},{rotation.to_gerber(register_variable)}' - - def substitute_params(self, binding, unit): - with self.Calculator(self, binding, unit) as calc: - rotation = calc.rotation - coords = [ rotate_point(x.calculate(binding, unit), y.calculate(binding, unit), -deg_to_rad(rotation), 0, 0) - for x, y in self.points ] + coords = self.coords + if isinstance(rotation, ConstantExpression): + rotation = math.radians(rotation.value) + # This will work even with variables in x and y, we just need to pass in cx and cy as UnitExpressions + unit_zero = UnitExpression(expr(0), MM) + coords = [ rotate_point(x, y, -rotation, cx=unit_zero, cy=unit_zero) for x, y in self.points ] coords = [ e for point in coords for e in point ] - return Outline(unit, calc.exposure, calc.length, coords) - def parameters(self): - yield from Primitive.parameters(self) + rotation = ConstantExpression(0) - for expr in self.coords: - yield from expr.parameters() + coords = ','.join(coord.to_gerber(unit) for coord in coords) + return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)//2-1},{coords},{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: @@ -400,10 +298,6 @@ class Outline(Primitive): bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ] bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ] bound_radii = [None] * len(bound_coords) - - if len(bound_coords) < 3: - return [] - return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))] def dilated(self, offset, unit): @@ -419,7 +313,7 @@ class Comment: code = 0 comment: str - def to_gerber(self, register_variable=None, settings=None): + def to_gerber(self, unit=None): return f'0 {self.comment}' def dilated(self, offset, unit): @@ -437,7 +331,6 @@ PRIMITIVE_CLASSES = { CenterLine, Outline, Polygon, - Moire, Thermal, ]}, # alternative codes diff --git a/src/gerbonara/apertures.py b/gerbonara/apertures.py similarity index 87% rename from src/gerbonara/apertures.py rename to gerbonara/apertures.py index fae129e..c49b599 100644 --- a/src/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -16,11 +16,11 @@ # limitations under the License. # -import warnings import math from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY from functools import lru_cache +from .aperture_macros.parse import GenericMacros from .utils import LengthUnit, MM, Inch, sum_bounds from . import graphic_primitives as gp @@ -150,8 +150,6 @@ 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() @@ -159,8 +157,7 @@ class ExcellonTool(Aperture): return self def to_macro(self, rotation=0): - from .aperture_macros.parse import GenericMacros - return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM) + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) def _params(self, unit=None): return (self.unit.convert_to(unit, self.diameter),) @@ -191,8 +188,6 @@ 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) @lru_cache() @@ -205,9 +200,7 @@ class CircleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*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)) + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) def _params(self, unit=None): return _strip_right( @@ -242,15 +235,13 @@ 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) @lru_cache() def rotated(self, angle=0): - if math.isclose(angle % math.pi, 0, abs_tol=1e-6): + if math.isclose(angle % math.pi, 0): return self - elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6): + elif math.isclose(angle % math.pi, math.pi/2): return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) else: # odd angle return self.to_macro(angle) @@ -262,11 +253,12 @@ class RectangleAperture(Aperture): hole_dia=None if self.hole_dia is None else self.hole_dia*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, + 0, + rotation)) def _params(self, unit=None): return _strip_right( @@ -303,8 +295,6 @@ 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) @lru_cache() @@ -330,11 +320,12 @@ class ObroundAperture(Aperture): rotation -= -math.pi/2 inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) - 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) or 0, + 0, + rotation)) def _params(self, unit=None): return _strip_right( @@ -371,8 +362,6 @@ 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 @@ -390,22 +379,16 @@ 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 @@ -435,13 +418,11 @@ class ApertureMacroInstance(Aperture): 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): + if math.isclose(angle % (2*math.pi), 0): return self else: return self.to_macro(angle) @@ -452,17 +433,9 @@ class ApertureMacroInstance(Aperture): 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 _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/data/center-pad-spikes.kicad_mod b/gerbonara/cad/data/center-pad-spikes.kicad_mod similarity index 100% rename from src/gerbonara/cad/data/center-pad-spikes.kicad_mod rename to gerbonara/cad/data/center-pad-spikes.kicad_mod diff --git a/src/gerbonara/cad/data/pad-between-spiked.kicad_mod b/gerbonara/cad/data/pad-between-spiked.kicad_mod similarity index 100% rename from src/gerbonara/cad/data/pad-between-spiked.kicad_mod rename to gerbonara/cad/data/pad-between-spiked.kicad_mod diff --git a/src/gerbonara/cad/data/tht-0.8.kicad_mod b/gerbonara/cad/data/tht-0.8.kicad_mod similarity index 100% rename from src/gerbonara/cad/data/tht-0.8.kicad_mod rename to gerbonara/cad/data/tht-0.8.kicad_mod diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py new file mode 100644 index 0000000..8f3036c --- /dev/null +++ b/gerbonara/cad/kicad/base_types.py @@ -0,0 +1,221 @@ +from .sexp import * +from .sexp_mapper import * +import time + +from dataclasses import field +import math +import uuid +from contextlib import contextmanager +from itertools import cycle + + +@sexp_type('color') +class Color: + r: int = None + g: int = None + b: int = None + a: int = None + + +@sexp_type('stroke') +class Stroke: + width: Named(float) = 0.254 + type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default + color: Color = None + + +class Dasher: + def __init__(self, obj): + if obj.stroke: + w, t = obj.stroke.width, obj.stroke.type + else: + w = obj.width or 0 + t = Atom.solid + + self.width = w + gap = 4*w + dot = 0 + dash = 11*w + self.pattern = { + Atom.dash: [dash, gap], + Atom.dot: [dot, gap], + Atom.dash_dot_dot: [dash, gap, dot, gap, dot, gap], + Atom.dash_dot: [dash, gap, dot, gap], + Atom.default: [1e99], + Atom.solid: [1e99]}[t] + self.solid = t in (Atom.default, Atom.solid) + self.start_x, self.start_y = None, None + self.cur_x, self.cur_y = None, None + self.segments = [] + + def move(self, x, y): + if self.cur_x is None: + self.start_x, self.start_y = x, y + self.cur_x, self.cur_y = x, y + + def line(self, x, y): + if x is None or y is None: + raise ValueError('line() called before move()') + self.segments.append((self.cur_x, self.cur_y, x, y)) + self.cur_x, self.cur_y = x, y + + def close(self): + self.segments.append((self.cur_x, self.cur_y, self.start_x, self.start_y)) + self.cur_x, self.cur_y = None, None + + @staticmethod + def _interpolate(x1, y1, x2, y2, length): + dx, dy = x2-x1, y2-y1 + total = math.hypot(dx, dy) + if total == 0: + return x2, y2 + frac = length / total + return x1 + dx*frac, y1 + dy*frac + + def __iter__(self): + it = iter(self.segments) + segment_remaining, segment_pos = 0, 0 + + if self.width is None or self.width < 1e-3: + return + + for length, stroked in cycle(zip(self.pattern, cycle([True, False]))): + length = max(1e-12, length) + import sys + while length > 0: + if segment_remaining == 0: + try: + x1, y1, x2, y2 = next(it) + except StopIteration: + return + dx, dy = x2-x1, y2-y1 + lx, ly = x1, y1 + segment_remaining = math.hypot(dx, dy) + segment_pos = 0 + + if segment_remaining > length: + segment_pos += length + ix, iy = self._interpolate(x1, y1, x2, y2, segment_pos) + segment_remaining -= length + if stroked: + yield lx, ly, ix, iy + lx, ly = ix, iy + break + + else: + length -= segment_remaining + segment_remaining = 0 + if stroked: + yield lx, ly, x2, y2 + + +@sexp_type('xy') +class XYCoord: + x: float = 0 + y: float = 0 + + def isclose(self, other, tol=1e-6): + return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol) + + +@sexp_type('pts') +class PointList: + xy : List(XYCoord) = field(default_factory=list) + + +@sexp_type('xyz') +class XYZCoord: + x: float = 0 + y: float = 0 + z: float = 0 + + +@sexp_type('at') +class AtPos(XYCoord): + x: float = 0 # in millimeter + y: float = 0 # in millimeter + rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270. + unlocked: Flag() = False + + def __before_sexp__(self): + self.rotation = int(round(self.rotation % 360)) + + @property + def rotation_rad(self): + return math.radians(self.rotation) + + @rotation_rad.setter + def rotation_rad(self, value): + self.rotation = math.degrees(value) + + +@sexp_type('font') +class FontSpec: + face: Named(str) = None + size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27)) + thickness: Named(float) = None + bold: Flag() = False + italic: Flag() = False + line_spacing: Named(float) = None + + +@sexp_type('justify') +class Justify: + h: AtomChoice(Atom.left, Atom.right) = None + v: AtomChoice(Atom.top, Atom.bottom) = None + mirror: Flag() = False + + +@sexp_type('effects') +class TextEffect: + font: FontSpec = field(default_factory=FontSpec) + justify: OmitDefault(Justify) = field(default_factory=Justify) + hide: Flag() = False + + +@sexp_type('tstamp') +class Timestamp: + value: str = field(default_factory=uuid.uuid4) + + def __after_parse__(self, parent): + self.value = str(self.value) + + def before_sexp(self): + self.value = Atom(str(self.value)) + + def bump(self): + self.value = uuid.uuid4() + +@sexp_type('tedit') +class EditTime: + value: str = field(default_factory=time.time) + + def __after_parse__(self, parent): + self.value = int(str(self.value), 16) + + def __before_sexp__(self): + self.value = Atom(f'{int(self.value):08X}') + + def bump(self): + self.value = time.time() + +if __name__ == '__main__': + class Foo: + pass + + foo = Foo() + foo.stroke = troke(0.01, Atom.dash_dot_dot) + d = Dasher(foo) + #d = Dasher(Stroke(0.01, Atom.solid)) + d.move(1, 1) + d.line(1, 2) + d.line(3, 2) + d.line(3, 1) + d.close() + + print('') + print('') + print('') + for x1, y1, x2, y2 in d: + print(f'') + print('') diff --git a/src/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py similarity index 58% rename from src/gerbonara/cad/kicad/footprints.py rename to gerbonara/cad/kicad/footprints.py index bb0f0f7..ece7b53 100644 --- a/src/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -2,7 +2,6 @@ Library for handling KiCad's footprint files (`*.kicad_mod`). """ -import re import copy import enum import string @@ -12,7 +11,7 @@ import time import fnmatch from itertools import chain from pathlib import Path -from dataclasses import field, replace +from dataclasses import field from .sexp import * from .base_types import * @@ -21,7 +20,6 @@ from . import graphical_primitives as gr from ..primitives import Positioned -from ... import __version__ from ... import graphic_primitives as gp from ... import graphic_objects as go from ... import apertures as ap @@ -32,22 +30,18 @@ from ...aperture_macros.parse import GenericMacros, ApertureMacro from ...aperture_macros import primitive as amp -class _MISSING: - pass +@sexp_type('property') +class Property: + key: str = '' + value: str = '' -def angle_difference(a, b): - return (b - a + math.pi) % (2*math.pi) - math.pi @sexp_type('attr') class Attribute: type: AtomChoice(Atom.smd, Atom.through_hole) = None board_only: Flag() = False - virtual: Flag() = False # prior to 20208026 exclude_from_pos_files: Flag() = False exclude_from_bom: Flag() = False - allow_missing_courtyard: Flag() = False - allow_soldermask_bridges: Flag() = False - dnp: Flag() = False @sexp_type('fp_text') @@ -55,9 +49,8 @@ class Text: type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user text: str = "" at: AtPos = field(default_factory=AtPos) - unlocked: OmitDefault(Named(YesNoAtom())) = False + unlocked: Flag() = False layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) hide: Flag() = False effects: TextEffect = field(default_factory=TextEffect) tstamp: Timestamp = None @@ -74,15 +67,12 @@ class TextBox: locked: Flag() = False text: str = None start: Rename(XYCoord) = None - end: Rename(XYCoord) = None - margins: Rename(gr.Margins) = None + end: Named(XYCoord) = None pts: PointList = None angle: Named(float) = 0.0 layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) tstamp: Timestamp = None effects: TextEffect = field(default_factory=TextEffect) - border: Named(YesNoAtom()) = False stroke: Stroke = field(default_factory=Stroke) render_cache: RenderCache = None @@ -95,23 +85,18 @@ class Line: start: Rename(XYCoord) = None end: Rename(XYCoord) = None layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) width: Named(float) = None stroke: Stroke = None locked: Flag() = False tstamp: Timestamp = None - def to_graphical_primitive(self, flip=False): - # FIXME flip - return gr.Line(self.start, self.end, self.layer, self.width, self.stroke, self.tstamp) - def render(self, variables=None, cache=None): dasher = Dasher(self) dasher.move(self.start.x, self.start.y) dasher.line(self.end.x, self.end.y) for x1, y1, x2, y2 in dasher: - yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) + yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) @sexp_type('fp_rect') @@ -119,10 +104,9 @@ class Rectangle: start: Rename(XYCoord) = None end: Rename(XYCoord) = None layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) width: Named(float) = None stroke: Stroke = None - fill: gr.FillMode = None + fill: Named(AtomChoice(Atom.solid, Atom.none)) = None locked: Flag() = False tstamp: Timestamp = None @@ -134,7 +118,7 @@ class Rectangle: w, h = x2-x1, y2-y1 if self.fill == Atom.solid: - yield go.Region.from_rectangle(x1, -y1, w, h, unit=MM) + yield go.Region.from_rectangle(x1, y1, w, h, unit=MM) dasher = Dasher(self) dasher.move(x1, y1) @@ -145,7 +129,7 @@ class Rectangle: aperture = ap.CircleAperture(dasher.width, unit=MM) for x1, y1, x2, y2 in dasher: - yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM) + yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM) @sexp_type('fp_circle') @@ -153,10 +137,9 @@ class Circle: center: Rename(XYCoord) = None end: Rename(XYCoord) = None layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) width: Named(float) = None stroke: Stroke = None - fill: gr.FillMode = None + fill: Named(AtomChoice(Atom.solid, Atom.none)) = None locked: Flag() = False tstamp: Timestamp = None @@ -167,7 +150,7 @@ class Circle: dasher = Dasher(self) aperture = ap.CircleAperture(dasher.width or 0, unit=MM) - circle = go.Arc.from_circle(x, -y, r, aperture=aperture, unit=MM) + circle = go.Arc.from_circle(x, y, r, aperture=aperture, unit=MM) if self.fill == Atom.solid: yield circle.to_region() @@ -181,25 +164,19 @@ class Circle: aperture = ap.CircleAperture(dasher.width, unit=MM) for x1, y1, x2, y2 in dasher: - yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM) - + yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM) @sexp_type('fp_arc') class Arc: start: Rename(XYCoord) = None mid: Rename(XYCoord) = None end: Rename(XYCoord) = None - width: Named(float) = None - angle: Named(float) = None - stroke: Stroke = None layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) + width: Named(float) = None + stroke: Stroke = None locked: Flag() = False tstamp: Timestamp = None - def to_graphical_primitive(self, flip=False): - # FIXME flip - return gr.Arc(self.start, self.mid, self.end, self.layer, self.width, self.stroke, self.tstamp) def render(self, variables=None, cache=None): mx, my = self.mid.x, self.mid.y @@ -211,7 +188,7 @@ class Arc: if math.isclose(x1, x2, abs_tol=1e-6) and math.isclose(y1, y2, abs_tol=1e-6): cx = (x1 + mx) / 2 cy = (y1 + my) / 2 - arc = go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), clockwise=True, aperture=aperture, unit=MM) + arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=True, aperture=aperture, unit=MM) if dasher.solid: yield arc @@ -221,7 +198,7 @@ class Arc: dasher.segments.append((line.x1, line.y1, line.x2, line.y2)) for line in dasher: - yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) + yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) else: # https://stackoverflow.com/questions/56224824/how-do-i-find-the-circumcenter-of-the-triangle-using-python-without-external-lib @@ -230,7 +207,7 @@ class Arc: cy = ((x1 * x1 + y1 * y1) * (mx - x2) + (x2 * x2 + y2 * y2) * (x1 - mx) + (mx * mx + my * my) * (x2 - x1)) / d # KiCad only has clockwise arcs. - arc = go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), clockwise=True, aperture=aperture, unit=MM) + arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=False, aperture=aperture, unit=MM) if dasher.solid: yield arc @@ -240,44 +217,41 @@ class Arc: dasher.segments.append((line.x1, line.y1, line.x2, line.y2)) for line in dasher: - yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) + yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) @sexp_type('fp_poly') class Polygon: - pts: PointList = field(default_factory=list) + pts: PointList = field(default_factory=PointList) layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) width: Named(float) = None stroke: Stroke = None - fill: gr.FillMode = None + fill: Named(AtomChoice(Atom.solid, Atom.none)) = None locked: Flag() = False tstamp: Timestamp = None def render(self, variables=None, cache=None): - if len(self.pts) < 2: + if len(self.pts.xy) < 2: return dasher = Dasher(self) - start = self.pts[0] + start = self.pts.xy[0] dasher.move(start.x, start.y) - for point in self.pts[1:]: + for point in self.pts.xy[1:]: dasher.line(point.x, point.y) - if dasher.width > 0: - aperture = ap.CircleAperture(dasher.width, unit=MM) - for x1, y1, x2, y2 in dasher: - yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM) + aperture = ap.CircleAperture(dasher.width, unit=MM) + for x1, y1, x2, y2 in dasher: + yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM) if self.fill == Atom.solid: - yield go.Region([(pt.x, -pt.y) for pt in self.pts], unit=MM) + yield go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM) @sexp_type('fp_curve') class Curve: - pts: PointList = field(default_factory=list) + pts: PointList = field(default_factory=PointList) layer: Named(str) = None - uuid: UUID = field(default_factory=UUID) width: Named(float) = None stroke: Stroke = None locked: Flag() = False @@ -287,6 +261,46 @@ class Curve: raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.') +@sexp_type('format') +class DimensionFormat: + prefix: Named(str) = None + suffix: Named(str) = None + units: Named(int) = 3 + units_format: Named(int) = 0 + precision: Named(int) = 3 + override_value: Named(str) = None + suppress_zeros: Flag() = False + + +@sexp_type('style') +class DimensionStyle: + thickness: Named(float) = None + arrow_length: Named(float) = None + text_position_mode: Named(int) = 0 + extension_height: Named(float) = None + text_frame: Named(int) = 0 + extension_offset: Named(str) = None + keep_text_aligned: Flag() = False + + +@sexp_type('dimension') +class Dimension: + locked: Flag() = False + type: AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial) = None + layer: Named(str) = None + tstamp: Timestamp = None + pts: PointList = field(default_factory=PointList) + height: Named(float) = None + orientation: Named(int) = 0 + leader_length: Named(float) = None + gr_text: Named(Text) = None + format: DimensionFormat = field(default_factory=DimensionFormat) + style: DimensionStyle = field(default_factory=DimensionStyle) + + def render(self, variables=None, cache=None): + raise NotImplementedError() + + @sexp_type('drill') class Drill: oval: Flag() = False @@ -295,6 +309,12 @@ class Drill: offset: Rename(XYCoord) = None +@sexp_type('net') +class NetDef: + number: int = None + name: str = None + + @sexp_type('options') class CustomPadOptions: clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline @@ -311,7 +331,7 @@ class CustomPadPrimitives: polygons: List(gr.Polygon) = field(default_factory=list) curves: List(gr.Curve) = field(default_factory=list) width: Named(float) = None - fill: gr.FillMode = True + fill: Named(YesNoAtom()) = True def all(self): yield from self.lines @@ -322,33 +342,36 @@ class CustomPadPrimitives: yield from self.curves +@sexp_type('chamfer') +class Chamfer: + top_left: Flag() = False + top_right: Flag() = False + bottom_left: Flag() = False + bottom_right: Flag() = False + + @sexp_type('pad') -class Pad(NetMixin): +class Pad: number: str = None type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None at: AtPos = field(default_factory=AtPos) - locked: Flag() = False + locked: Wrap(Flag()) = False size: Rename(XYCoord) = field(default_factory=XYCoord) drill: Drill = None layers: Named(Array(str)) = field(default_factory=list) properties: List(Property) = field(default_factory=list) - remove_unused_layers: Named(YesNoAtom()) = False - keep_end_layers: Named(YesNoAtom()) = False - zone_layer_connections: Named(Array(str)) = field(default_factory=list) - uuid: UUID = field(default_factory=UUID) + remove_unused_layers: Wrap(Flag()) = False + keep_end_layers: Wrap(Flag()) = False rect_delta: Rename(XYCoord) = None roundrect_rratio: Named(float) = None thermal_bridge_angle: Named(int) = 45 - thermal_bridge_width: Named(float) = 0.5 chamfer_ratio: Named(float) = None chamfer: Chamfer = None - net: Net = None + net: NetDef = None tstamp: Timestamp = None pin_function: Named(str) = None pintype: Named(str) = None - pinfunction: Named(str) = None - teardrops: gr.TeardropSpec = None die_length: Named(float) = None solder_mask_margin: Named(float) = None solder_paste_margin: Named(float) = None @@ -358,41 +381,7 @@ class Pad(NetMixin): thermal_width: Named(float) = None thermal_gap: Named(float) = None options: OmitDefault(CustomPadOptions) = None - padstack: gr.PadStack = None primitives: OmitDefault(CustomPadPrimitives) = None - _: SEXP_END = None - footprint: object = field(repr=False, default=None) - - def __after_parse__(self, parent=None): - self.layers = unfuck_layers(self.layers) - - def __before_sexp__(self): - self.layers = fuck_layers(self.layers) - - @property - def abs_pos(self): - if self.footprint: - px, py, pr = self.footprint.at.x, self.footprint.at.y, self.footprint.at.rotation - else: - px, py, pr = 0, 0, 0 - - x, y = rotate_point(self.at.x, self.at.y, math.radians(pr)) - return x+px, y+py, self.at.rotation, False - - @property - def layer_mask(self): - return layer_mask(self.layers) - - def offset(self, x=0, y=0): - self.at = self.at.with_offset(x, y) - - def find_connected_footprints(self, **filters): - """ Find footprints connected to the same net as this pad """ - return self.footprint.board.find_footprints(net=self.net.name, **filters) - - def find_same_net(self, include_vias=True): - """ Find traces and vias of the same net as this pad. """ - return self.footprint.board.find_traces(self.net.name, include_vias=include_vias) def render(self, variables=None, margin=None, cache=None): #if self.type in (Atom.connect, Atom.np_thru_hole): @@ -412,7 +401,7 @@ class Pad(NetMixin): else: aperture = self.aperture(margin) - yield go.Flash(self.at.x+ox, -(self.at.y+oy), aperture, unit=MM) + yield go.Flash(self.at.x+ox, self.at.y+oy, aperture, unit=MM) def aperture(self, margin=None): rotation = math.radians(self.at.rotation) @@ -423,16 +412,16 @@ class Pad(NetMixin): elif self.shape == Atom.rect: if margin > 0: - return GenericMacros.rounded_rect(self.size.x+2*margin, - self.size.y+2*margin, - margin, - 0, # no hole - rotation) + return ap.ApertureMacroInstance(GenericMacros.rounded_rect, + (self.size.x+2*margin, self.size.y+2*margin, + margin, + 0, 0, # no hole + rotation), unit=MM) else: - return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) + return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation) elif self.shape == Atom.oval: - return ap.ObroundAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) + return ap.ObroundAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation) elif self.shape == Atom.trapezoid: # KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably @@ -454,31 +443,30 @@ class Pad(NetMixin): # Note: KiCad already uses MM units, so no conversion needed here. alpha = math.atan(y / dy) if dy > 0 else 0 - return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha), - y+2*margin, - 2*dy, - 0, # no hole - -rotation + math.pi) + return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid, + (x+dy+2*margin*math.cos(alpha), y+2*margin, + 2*dy, + 0, 0, # no hole + rotation), unit=MM) else: - return GenericMacros.rounded_isosceles_trapezoid(x+dy, - y, - 2*dy, - margin, - 0, # no hole - -rotation + math.pi) + return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid, + (x+dy, y, + 2*dy, margin, + 0, 0, # no hole + rotation), unit=MM) elif self.shape == Atom.roundrect: x, y = self.size.x, self.size.y r = min(x, y) * self.roundrect_rratio if margin > -r: - return GenericMacros.rounded_rect(x+2*margin, - y+2*margin, - r+margin, - 0, # no hole - rotation) + return ap.ApertureMacroInstance(GenericMacros.rounded_rect, + (x+2*margin, y+2*margin, + r+margin, + 0, 0, # no hole + rotation), unit=MM) else: - return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation) + return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(rotation) elif self.shape == Atom.custom: primitives = [] @@ -487,7 +475,7 @@ class Pad(NetMixin): for obj in self.primitives.all(): for gn_obj in obj.render(): if margin and isinstance(gn_obj, (go.Line, go.Arc)): - gn_obj = replace(gn_obj, aperture=gn_obj.aperture.dilated(margin)) + gn_obj = gn_obj.dilated(margin) if isinstance(gn_obj, go.Region) and margin > 0: for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)): @@ -520,7 +508,7 @@ class Pad(NetMixin): elif self.options.anchor == Atom.circle and self.size.x > 0: primitives.append(amp.Circle(MM, 1, self.size.x+2*margin, 0, 0, 0)) - macro = ApertureMacro(primitives=tuple(primitives)).rotated(-rotation) + macro = ApertureMacro(primitives=tuple(primitives)).rotated(rotation) return ap.ApertureMacroInstance(macro, unit=MM) def render_drill(self): @@ -545,40 +533,38 @@ class Pad(NetMixin): dy = 0 aperture = ap.ExcellonTool(min(dia, w), plated=plated, unit=MM) - l = go.Line(ox-dx, -(oy-dy), ox+dx, -(oy+dy), aperture=aperture, unit=MM) + l = go.Line(ox-dx, oy-dy, ox+dx, oy+dy, aperture=aperture, unit=MM) l.rotate(math.radians(self.at.rotation)) - l.offset(self.at.x, -self.at.y) + l.offset(self.at.x, self.at.y) yield l else: aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM) - yield go.Flash(self.at.x, -self.at.y, aperture=aperture, unit=MM) + yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM) + + +@sexp_type('group') +class Group: + name: str = "" + id: Named(str) = "" + members: Named(List(str)) = field(default_factory=list) @sexp_type('model') class Model: name: str = '' - hide: Flag() = False at: Named(XYZCoord) = field(default_factory=XYZCoord) offset: Named(XYZCoord) = field(default_factory=XYZCoord) - opacity: Named(float) = None scale: Named(XYZCoord) = field(default_factory=XYZCoord) rotate: Named(XYZCoord) = field(default_factory=XYZCoord) -@sexp_type('component_classes') -class FootprintComponentClasses: - classes: List(Named(str, name='class')) = field(default_factory=list) - - -SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517] +SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018] @sexp_type('footprint') class Footprint: name: str = None - _version: Named(int, name='version') = 20221018 - uuid: UUID = field(default_factory=UUID) - generator: Named(str) = Atom.gerbonara - generator_version: Named(str) = __version__ + _version: Named(int, name='version') = 20210108 + generator: Named(Atom) = Atom.kicad_library_utils locked: Flag() = False placed: Flag() = False layer: Named(str) = 'F.Cu' @@ -587,24 +573,20 @@ class Footprint: at: AtPos = field(default_factory=AtPos) descr: Named(str) = None tags: Named(str) = None - properties: List(DrawnProperty) = field(default_factory=list) - component_classes: FootprintComponentClasses = None + properties: List(Property) = field(default_factory=list) path: Named(str) = None - sheetname: Named(str) = None - sheetfile: Named(str) = None autoplace_cost90: Named(float) = None autoplace_cost180: Named(float) = None solder_mask_margin: Named(float) = None - solder_paste_margin_ratio: Named(float) = None solder_paste_margin: Named(float) = None solder_paste_ratio: Named(float) = None clearance: Named(float) = None zone_connect: Named(int) = None thermal_width: Named(float) = None thermal_gap: Named(float) = None - attributes: Attribute = field(default_factory=Attribute) + attributes: List(Attribute) = field(default_factory=list) private_layers: Named(str) = None - net_tie_pad_groups: Named(Array(str)) = None + net_tie_pad_groups: Named(str) = None texts: List(Text) = field(default_factory=list) text_boxes: List(TextBox) = field(default_factory=list) lines: List(Line) = field(default_factory=list) @@ -613,98 +595,14 @@ class Footprint: arcs: List(Arc) = field(default_factory=list) polygons: List(Polygon) = field(default_factory=list) curves: List(Curve) = field(default_factory=list) - dimensions: List(gr.Dimension) = field(default_factory=list) + dimensions: List(Dimension) = field(default_factory=list) pads: List(Pad) = field(default_factory=list) zones: List(Zone) = field(default_factory=list) groups: List(Group) = field(default_factory=list) - embedded_fonts: Named(YesNoAtom()) = False models: List(Model) = field(default_factory=list) _ : SEXP_END = None original_filename: str = None - board: object = field(repr=False, default=None) - - def __after_parse__(self, parent): - for pad in self.pads: - pad.footprint = self - - def property_value(self, key, default=_MISSING): - for prop in self.properties: - if prop.key == key: - return prop.value - - if default is not _MISSING: - return default - - raise IndexError(f'Footprint has no property named "{key}"') - - def set_property(self, key, value, x=0, y=0, rotation=0, layer='F.Fab', hide=True, effects=None): - for prop in self.properties: - if prop.key == key: - old_value, prop.value = prop.value, value - return old_value - - if effects is None: - effects = TextEffect() - - self.properties.append(DrawnProperty(key, value, - at=AtPos(x, y, rotation, unlocked=True), - layer=layer, - hide=hide, - effects=effects)) - - def make_standard_properties(self): - if not self.property_value('Reference', None): - self.set_property('Reference', 'REF**', 0, 0, 0, 'F.SilkS') - - if not self.property_value('Value', None): - self.set_property('Value', self.name or 'VAL**', 0, 0, 0, hide=False) - - if not self.property_value('Footprint', None): - self.set_property('Footprint', '', 0, 0, 0) - - if not self.property_value('Datasheet', None): - self.set_property('Datasheet', '', 0, 0, 0) - - if not self.property_value('Description', None): - self.set_property('Description', self.descr or '', 0, 0, 0) - - def reset_nets(self): - for pad in self.pads: - pad.reset_net() - - @property - def pads_by_number(self): - return {(int(pad.number) if pad.number.isnumeric() else pad.number): pad for pad in self.pads if pad.number} - - def find_pads(self, number=None, net=None): - for pad in self.pads: - if number is not None and pad.number == str(number): - yield pad - elif isinstance(net, str) and fnmatch.fnmatch(pad.net.name, net): - yield pad - elif net is not None and pad.net.number == net: - yield pad - - def pad(self, number=None, net=None): - candidates = list(self.find_pads(number=number, net=net)) - if not candidates: - raise IndexError(f'No such pad "{number or net}"') - - if len(candidates) > 1: - raise IndexError(f'Ambiguous pad "{number or net}", {len(candidates)} matching pads.') - - return candidates[0] - - def offset(self, x=0, y=0): - self.at = self.at.with_offset(x, y) - - def copy_placement(self, template): - # Fix up rotation of pads - KiCad saves each pad's rotation in *absolute* coordinates, not relative to the - # footprint. Because we overwrite the footprint's rotation below, we have to first fix all pads to match the - # new rotation. - self.rotate(math.radians(template.at.rotation - self.at.rotation)) - self.at = copy.copy(template.at) - self.side = template.side + _bounding_box: tuple = None @property def version(self): @@ -715,36 +613,9 @@ class Footprint: if value not in SUPPORTED_FILE_FORMAT_VERSIONS: raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') - @property - def reference(self): - return self.property_value('Reference') - - @reference.setter - def reference(self, value): - self.set_property('Reference', value) - - @property - def parsed_reference(self): - ref = self.reference - if (m := re.match(r'^.*[^0-9]([0-9]+)$', ref)): - return m.group(0), int(m.group(1)) - else: - return ref - - @property - def value(self): - return self.property_value('Value') - - @value.setter - def value(self, value): - self.set_property('Value', value) - def write(self, filename=None): with open(filename or self.original_filename, 'w') as f: - f.write(self.serialize()) - - def serialize(self): - return build_sexp(sexp(type(self), self)[0]) + f.write(build_sexp(sexp(self))) @classmethod def open_pretty(kls, pretty_dir, fp_name, *args, **kwargs): @@ -767,111 +638,11 @@ class Footprint: def load(kls, data, *args, **kwargs): return kls.parse(data, *args, **kwargs) - @property - def side(self): - return 'front' if self.layer == 'F.Cu' else 'back' - - @side.setter - def side(self, value): - if value not in ('front', 'back'): - raise ValueError(f'side must be either "front" or "back", not {side!r}') - - if self.side != value: - self.flip() - - def flip(self): - def flip_layer(name): - if name.startswith('F.'): - return f'B.{name[2:]}' - elif name.startswith('B.'): - return f'F.{name[2:]}' - else: - return name - - self.layer = flip_layer(self.layer) - for obj in self.objects(): - if getattr(obj, 'layer', None) is not None: - obj.layer = flip_layer(obj.layer) - - if hasattr(obj, 'layers'): - obj.layers = [flip_layer(name) for name in obj.layers] - - for obj in chain(self.texts, self.text_boxes): - obj.effects.justify.mirror = not obj.effects.justify.mirror - - for obj in self.properties: - if obj.layer is not None: - obj.effects.justify.mirror = not obj.effects.justify.mirror - obj.layer = flip_layer(obj.layer) - @property def single_sided(self): raise NotImplementedError() - - def face(self, direction, pad=None, net=None): - if not net and not pad: - pad = '1' - candidates = list(self.find_pads(net=net, number=pad)) - if len(candidates) == 0: - raise KeyError(f'Reference pad "{net or pad}" not found.') - - if len(candidates) > 1: - raise KeyError(f'Reference pad "{net or pad}" is ambiguous, {len(candidates)} matching pads found.') - - pad = candidates[0] - pad_angle = math.atan2(pad.at.y, pad.at.x) - - target_angle = { - 'right': 0, - 'top right': math.pi/4, - 'top': math.pi/2, - 'top left': 3*math.pi/4, - 'left': math.pi, - 'bottom left': -3*math.pi/4, - 'bottom': -math.pi/2, - 'bottom right': -math.pi/4}.get(direction, direction) - - delta = angle_difference(target_angle, pad_angle) - adj = round(delta / (math.pi/2)) * math.pi/2 - self.set_rotation(adj) - - def rotate(self, angle=None, cx=None, cy=None, **reference_pad): - """ Rotate this footprint by the given angle in radians, counter-clockwise. When (cx, cy) are given, rotate - around the given coordinates in the global coordinate space. Otherwise rotate around the footprint's origin. """ - if (cx, cy) != (None, None): - x, y = self.at.x-cx, self.at.y-cy - self.at.x = math.cos(-angle)*x - math.sin(-angle)*y + cx - self.at.y = math.sin(-angle)*x + math.cos(-angle)*y + cy - - self.at.rotation = (self.at.rotation + math.degrees(angle)) % 360 - - for pad in self.pads: - pad.at.rotation = (pad.at.rotation + math.degrees(angle)) % 360 - - for prop in self.properties: - if prop.at is not None: - prop.at.rotation = (prop.at.rotation + math.degrees(angle)) % 360 - - for text in self.texts: - text.at.rotation = (text.at.rotation + math.degrees(angle)) % 360 - - def set_rotation(self, angle): - old_deg = self.at.rotation - new_deg = self.at.rotation = -math.degrees(angle) - delta = new_deg - old_deg - - for pad in self.pads: - pad.at.rotation = (pad.at.rotation + delta) % 360 - - for prop in self.properties: - if prop.at is not None: - prop.at.rotation = (prop.at.rotation + delta) % 360 - - for text in self.texts: - text.at.rotation = (text.at.rotation + delta) % 360 - - def objects(self, text=False, pads=True, groups=True, zones=True): + def objects(self, text=False, pads=True): return chain( (self.texts if text else []), (self.text_boxes if text else []), @@ -882,19 +653,15 @@ class Footprint: self.polygons, self.curves, (self.dimensions if text else []), - (self.pads if pads else []), - (self.zones if zones else []), - self.groups if groups else []) + (self.pads if pads else [])) - def render(self, layer_stack, layer_map=None, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None): + def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, side=None, variables={}, cache=None): x += self.at.x y += self.at.y rotation += math.radians(self.at.rotation) + flip = (side != 'top') if side else (self.layer != 'F.Cu') - if layer_map is None: - layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack} - - for obj in self.objects(pads=False, text=text, zones=False, groups=False): + for obj in self.objects(pads=False, text=text): if not (layer := layer_map.get(obj.layer)): continue @@ -948,13 +715,38 @@ class Footprint: layer_stack.drill_pth.append(fe) def bounding_box(self, unit=MM): - if not hasattr(self, '_bounding_box'): + if not self._bounding_box: stack = LayerStack() - self.render(stack, layer_map=None, x=0, y=0, rotation=0, flip=False, text=False, variables={}) + layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack} + self.render(stack, layer_map, x=0, y=0, rotation=0, side='top', text=False, variables={}) self._bounding_box = stack.bounding_box(unit) return self._bounding_box + +LAYER_MAP_K2G = { + 'F.Cu': ('top', 'copper'), + 'B.Cu': ('bottom', 'copper'), + 'F.SilkS': ('top', 'silk'), + 'B.SilkS': ('bottom', 'silk'), + 'F.Paste': ('top', 'paste'), + 'B.Paste': ('bottom', 'paste'), + 'F.Mask': ('top', 'mask'), + 'B.Mask': ('bottom', 'mask'), + 'B.CrtYd': ('bottom', 'courtyard'), + 'F.CrtYd': ('top', 'courtyard'), + 'B.Fab': ('bottom', 'fabrication'), + 'F.Fab': ('top', 'fabrication'), + 'B.Adhes': ('bottom', 'adhesive'), + 'F.Adhes': ('top', 'adhesive'), + 'Dwgs.User': ('mechanical', 'drawings'), + 'Cmts.User': ('mechanical', 'comments'), + 'Edge.Cuts': ('mechanical', 'outline'), + } + +LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()} + + @dataclass class FootprintInstance(Positioned): sexp: Footprint = None @@ -964,7 +756,7 @@ class FootprintInstance(Positioned): variables: dict = field(default_factory=lambda: {}) def render(self, layer_stack, cache=None): - x, y, rotation, flip= self.abs_pos + x, y, rotation = self.abs_pos x, y = MM(x, self.unit), MM(y, self.unit) variables = dict(self.variables) @@ -975,16 +767,17 @@ class FootprintInstance(Positioned): if self.value is not None: variables['VALUE'] = str(self.value) - self.sexp.render(layer_stack, layer_map=None, + layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack} + + self.sexp.render(layer_stack, layer_map, x=x, y=y, rotation=rotation, - flip=flip, + side=self.side, text=(not self.hide_text), variables=variables, cache=cache) def bounding_box(self, unit=MM): return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit)) - if __name__ == '__main__': import sys from ...layers import LayerStack diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py new file mode 100644 index 0000000..171fa76 --- /dev/null +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -0,0 +1,230 @@ + +import string +import math + +from .sexp import * +from .base_types import * +from .primitives import * + +from ... import graphic_objects as go +from ... import apertures as ap +from ...newstroke import Newstroke +from ...utils import rotate_point, MM + +@sexp_type('layer') +class TextLayer: + layer: str = '' + knockout: Flag() = False + + +@sexp_type('gr_text') +class Text: + text: str = '' + at: AtPos = field(default_factory=AtPos) + layer: TextLayer = field(default_factory=TextLayer) + tstamp: Timestamp = None + effects: TextEffect = field(default_factory=TextEffect) + + def render(self, variables={}): + if not self.effects or self.effects.hide or not self.effects.font: + return + + font = Newstroke.load() + line_width = self.effects.font.thickness + text = string.Template(self.text).safe_substitute(variables) + strokes = list(font.render(text, size=self.effects.font.size.y)) + min_x = min(x for st in strokes for x, y in st) + min_y = min(y for st in strokes for x, y in st) + max_x = max(x for st in strokes for x, y in st) + max_y = max(y for st in strokes for x, y in st) + w = max_x - min_x + h = max_y - min_y + + offx = -min_x + { + None: -w/2, + Atom.right: -w, + Atom.left: 0 + }[self.effects.justify.h if self.effects.justify else None] + + offy = { + None: self.effects.font.size.y/2, + Atom.top: self.effects.font.size.y, + Atom.bottom: 0 + }[self.effects.justify.v if self.effects.justify else None] + + aperture = ap.CircleAperture(line_width or 0.2, unit=MM) + for stroke in strokes: + out = [] + + for x, y in stroke: + x, y = x+offx, y+offy + x, y = rotate_point(x, y, math.radians(self.at.rotation or 0)) + x, y = x+self.at.x, y+self.at.y + out.append((x, y)) + + for p1, p2 in zip(out[:-1], out[1:]): + yield go.Line(*p1, *p2, aperture=aperture, unit=MM) + + +@sexp_type('gr_text_box') +class TextBox: + locked: Flag() = False + text: str = '' + start: Named(XYCoord) = None + end: Named(XYCoord) = None + pts: PointList = field(default_factory=PointList) + angle: OmitDefault(Named(float)) = 0.0 + layer: Named(str) = "" + tstamp: Timestamp = None + effects: TextEffect = field(default_factory=TextEffect) + stroke: Stroke = field(default_factory=Stroke) + render_cache: RenderCache = None + + def render(self, variables={}): + text = string.Template(self.text).safe_substitute(variables) + if text != self.text: + raise ValueError('Rendering of vector font text with variables not yet supported') + + if not render_cache or not render_cache.polygons: + raise ValueError('Vector font text with empty render cache') + + for poly in render_cache.polygons: + reg = go.Region([(p.x, p.y) for p in poly.pts.xy], unit=MM) + + if self.stroke: + if self.stroke.type not in (None, Atom.default, Atom.solid): + raise ValueError('Dashed strokes are not supported on vector text') + + yield from reg.outline_objects(aperture=ap.CircleAperture(self.stroke.width, unit=MM)) + + yield reg + + +@sexp_type('gr_line') +class Line: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + angle: Named(float) = None # wat + layer: Named(str) = None + width: Named(float) = None + tstamp: Timestamp = None + + def render(self, variables=None): + if self.angle: + raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.') + + aperture = ap.CircleAperture(self.width, unit=MM) + yield go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=aperture, unit=MM) + + +@sexp_type('fill') +class FillMode: + # Needed for compatibility with weird files + fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False + + @classmethod + def __map__(self, obj, parent=None): + return obj[1] in (Atom.solid, Atom.yes) + + @classmethod + def __sexp__(self, value): + yield [Atom.fill, Atom.solid if value else Atom.none] + +@sexp_type('gr_rect') +class Rectangle: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + fill: FillMode = False + tstamp: Timestamp = None + + def render(self, variables=None): + rect = go.Region.from_rectangle(self.start.x, self.start.y, + self.end.x-self.start.x, self.end.y-self.start.y, + unit=MM) + + if self.fill: + yield rect + + if self.width: + yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM)) + + +@sexp_type('gr_circle') +class Circle: + center: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + fill: FillMode = False + tstamp: Timestamp = None + + def render(self, variables=None): + r = math.dist((self.center.x, self.center.y), (self.end.x, self.end.y)) + aperture = ap.CircleAperture(self.width or 0, unit=MM) + arc = go.Arc.from_circle(self.center.x, self.center.y, r, aperture=aperture, unit=MM) + + if self.width: + yield arc + + if self.fill: + yield arc.to_region() + + +@sexp_type('gr_arc') +class Arc: + start: Rename(XYCoord) = None + mid: Rename(XYCoord) = None + end: Rename(XYCoord) = None + layer: Named(str) = None + width: Named(float) = None + tstamp: Timestamp = None + + def render(self, variables=None): + if not self.width: + return + + cx, cy = self.mid.x, self.mid.y + x1, y1 = self.start.x, self.start.y + x2, y2 = self.end.x, self.end.y + yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=ap.CircleAperture(self.width or 0, unit=MM), clockwise=True, unit=MM) + + +@sexp_type('gr_poly') +class Polygon: + pts: PointList = field(default_factory=PointList) + layer: Named(str) = None + width: Named(float) = None + fill: FillMode = True + tstamp: Timestamp = None + + def render(self, variables=None): + reg = go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM) + + if self.width and self.width >= 0.005: + yield from reg.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM)) + + if self.fill: + yield reg + + +@sexp_type('gr_curve') +class Curve: + pts: PointList = field(default_factory=PointList) + layer: Named(str) = None + width: Named(float) = None + tstamp: Timestamp = None + + def render(self, variables=None): + raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.') + + +@sexp_type('gr_bbox') +class AnnotationBBox: + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + + def render(self, variables=None): + return [] + diff --git a/src/gerbonara/cad/kicad/layer_colors.py b/gerbonara/cad/kicad/layer_colors.py similarity index 100% rename from src/gerbonara/cad/kicad/layer_colors.py rename to gerbonara/cad/kicad/layer_colors.py diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py new file mode 100644 index 0000000..30ae611 --- /dev/null +++ b/gerbonara/cad/kicad/primitives.py @@ -0,0 +1,97 @@ + +import enum + +from .sexp import * +from .base_types import * + + +@sexp_type('hatch') +class Hatch: + style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge + pitch: float = 0.5 + + +@sexp_type('connect_pads') +class PadConnection: + type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None + clearance: Named(float) = 0 + + +@sexp_type('keepout') +class ZoneKeepout: + tracks_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='tracks') = True + vias_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='vias') = True + pads_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='pads') = True + copperpour_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='copperpour') = True + footprints_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='footprints') = True + + +@sexp_type('smoothing') +class ZoneSmoothing: + style: AtomChoice(Atom.chamfer, Atom.fillet) = Atom.chamfer + radius: Named(float) = None + + +@sexp_type('fill') +class ZoneFill: + yes: Flag() = False + mode: Flag(atom=Atom.hatched) = False + thermal_gap: Named(float) = 0.508 + thermal_bridge_width: Named(float) = 0.508 + smoothing: ZoneSmoothing = None + island_removal_node: Named(int) = None + islan_area_min: Named(float) = None + hatch_thickness: Named(float) = None + hatch_gap: Named(float) = None + hatch_orientation: Named(int) = None + hatch_smoothing_level: Named(int) = None + hatch_smoothing_value: Named(float) = None + hatch_border_algorithm: Named(int) = None + hatch_min_hole_area: Named(float) = None + + +@sexp_type('filled_polygon') +class FillPolygon: + layer: Named(str) = "" + pts: PointList = field(default_factory=PointList) + + +@sexp_type('fill_segments') +class FillSegment: + layer: Named(str) = "" + pts: PointList = field(default_factory=PointList) + + +@sexp_type('zone') +class Zone: + net: Named(int) = 0 + net_name: Named(str) = "" + layer: Named(str) = None + layers: Named(Array(str)) = None + tstamp: Timestamp = None + name: Named(str) = None + hatch: Hatch = None + priority: OmitDefault(Named(int)) = 0 + connect_pads: PadConnection = field(default_factory=PadConnection) + min_thickness: Named(float) = 0.254 + filled_areas_thickness: Flag() = True + keepouts: List(ZoneKeepout) = field(default_factory=list) + fill: ZoneFill = field(default_factory=ZoneFill) + polygon: Named(PointList) = field(default_factory=PointList) + fill_polygons: List(FillPolygon) = field(default_factory=list) + fill_segments: List(FillSegment) = field(default_factory=list) + + +@sexp_type('polygon') +class RenderCachePolygon: + pts: PointList = field(default_factory=PointList) + + +@sexp_type('render_cache') +class RenderCache: + text: str = None + rotation: int = 0 + polygons: List(RenderCachePolygon) = field(default_factory=list) + + + diff --git a/src/gerbonara/cad/kicad/sexp.py b/gerbonara/cad/kicad/sexp.py similarity index 99% rename from src/gerbonara/cad/kicad/sexp.py rename to gerbonara/cad/kicad/sexp.py index edc31c2..9312489 100644 --- a/src/gerbonara/cad/kicad/sexp.py +++ b/gerbonara/cad/kicad/sexp.py @@ -64,7 +64,7 @@ term_regex = r"""(?mx) (\))| ([+-]?\d+\.\d+(?=[\s\)]))| (\-?\d+(?=[\s\)]))| - ([^"\s()][^"\s)]*) + ([^0-9"\s()][^"\s)]*) )""" diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py new file mode 100644 index 0000000..1d0f942 --- /dev/null +++ b/gerbonara/cad/kicad/sexp_mapper.py @@ -0,0 +1,289 @@ + +from dataclasses import MISSING +from .sexp import * + + +SEXP_END = type('SEXP_END', (), {}) + + +class AtomChoice: + def __init__(self, *choices): + self.choices = choices + + def __contains__(self, value): + return value in self.choices + + def __atoms__(self): + return self.choices + + def __map__(self, obj, parent=None): + obj, = obj + if obj not in self: + raise TypeError(f'Invalid atom {obj} for {type(self)}, valid choices are: {", ".join(map(str, self.choices))}') + return obj + + def __sexp__(self, value): + yield value + + +class Flag: + def __init__(self, atom=None, invert=None): + self.atom, self.invert = atom, invert + + def __bind_field__(self, field): + if self.atom is None: + self.atom = Atom(field.name) + if self.invert is None: + self.invert = bool(field.default) + + def __atoms__(self): + return [self.atom] + + def __map__(self, obj, parent=None): + return not self.invert + + def __sexp__(self, value): + if bool(value) == (not self.invert): + yield self.atom + + +def sexp(t, v): + if v is None: + return [] + elif t in (int, float, str, Atom): + return [t(v)] + elif hasattr(t, '__sexp__'): + return list(t.__sexp__(v)) + elif isinstance(t, list): + t, = t + return [sexp(t, elem) for elem in v] + else: + raise TypeError(f'Python type {t} has no defined s-expression serialization') + + +def map_sexp(t, v, parent=None): + if t is not Atom and hasattr(t, '__map__'): + return t.__map__(v, parent=parent) + elif t in (int, float, str, Atom): + v, = v + if not isinstance(v, t): + types = set({type(v), t}) + if types == {int, float} or types == {str, Atom}: + v = t(v) + else: + raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}') + return v + elif isinstance(t, list): + t, = t + return [map_sexp(t, elem, parent=parent) for elem in v] + else: + raise TypeError(f'Python type {t} has no defined s-expression deserialization') + + +class WrapperType: + def __init__(self, next_type): + self.next_type = next_type + + def __bind_field__(self, field): + self.field = field + getattr(self.next_type, '__bind_field__', lambda x: None)(field) + + def __atoms__(self): + if hasattr(self, 'name_atom'): + return [self.name_atom] + elif self.next_type is Atom: + return [] + else: + return getattr(self.next_type, '__atoms__', lambda: [])() + +class Named(WrapperType): + def __init__(self, next_type, name=None): + super().__init__(next_type) + self.name_atom = Atom(name) if name else None + + def __bind_field__(self, field): + if self.next_type is not Atom: + getattr(self.next_type, '__bind_field__', lambda x: None)(field) + if self.name_atom is None: + self.name_atom = Atom(field.name) + + def __map__(self, obj, parent=None): + k, *obj = obj + if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice): + return map_sexp(self.next_type, [*obj], parent=parent) + else: + return map_sexp(self.next_type, obj, parent=parent) + + def __sexp__(self, value): + value = sexp(self.next_type, value) + if value is not None: + yield [self.name_atom, *value] + + +class Rename(WrapperType): + def __init__(self, next_type, name=None): + super().__init__(next_type) + self.name_atom = Atom(name) if name else None + + def __bind_field__(self, field): + if self.name_atom is None: + self.name_atom = Atom(field.name) + + def __map__(self, obj, parent=None): + return map_sexp(self.next_type, obj, parent=parent) + + def __sexp__(self, value): + value, = sexp(self.next_type, value) + if self.next_type in (str, float, int, Atom): + yield [self.name_atom, *value] + else: + key, *rest = value + yield [self.name_atom, *rest] + + +class OmitDefault(WrapperType): + def __bind_field__(self, field): + getattr(self.next_type, '__bind_field__', lambda x: None)(field) + if field.default_factory != MISSING: + self.default = field.default_factory() + else: + self.default = field.default + + def __map__(self, obj, parent=None): + return map_sexp(self.next_type, obj, parent=parent) + + def __sexp__(self, value): + if value != self.default: + yield from sexp(self.next_type, value) + + +class YesNoAtom: + def __init__(self, yes=Atom.yes, no=Atom.no): + self.yes, self.no = yes, no + + def __map__(self, value, parent=None): + value, = value + return value == self.yes + + def __sexp__(self, value): + yield self.yes if value else self.no + + +class Wrap(WrapperType): + def __map__(self, value, parent=None): + value, = value + return map_sexp(self.next_type, value, parent=parent) + + def __sexp__(self, value): + for inner in sexp(self.next_type, value): + yield [inner] + + +class Array(WrapperType): + def __map__(self, value, parent=None): + return [map_sexp(self.next_type, [elem], parent=parent) for elem in value] + + def __sexp__(self, value): + for e in value: + yield from sexp(self.next_type, e) + + +class List(WrapperType): + def __bind_field__(self, field): + self.attr = field.name + + def __map__(self, value, parent): + l = getattr(parent, self.attr, []) + mapped = map_sexp(self.next_type, value, parent=parent) + l.append(mapped) + setattr(parent, self.attr, l) + + def __sexp__(self, value): + for elem in value: + yield from sexp(self.next_type, elem) + + +class _SexpTemplate: + @staticmethod + def __atoms__(kls): + return [kls.name_atom] + + @staticmethod + def __map__(kls, value, *args, parent=None, **kwargs): + positional = iter(kls.positional) + inst = kls(*args, **kwargs) + + for v in value[1:]: # skip key + if isinstance(v, Atom) and v in kls.keys: + name, etype = kls.keys[v] + mapped = map_sexp(etype, [v], parent=inst) + if mapped is not None: + setattr(inst, name, mapped) + + elif isinstance(v, list): + name, etype = kls.keys[v[0]] + mapped = map_sexp(etype, v, parent=inst) + if mapped is not None: + setattr(inst, name, mapped) + + else: + try: + pos_key = next(positional) + setattr(inst, pos_key.name, v) + except StopIteration: + raise TypeError(f'Unhandled positional argument {v!r} while parsing {kls}') + + getattr(inst, '__after_parse__', lambda x: None)(parent) + return inst + + @staticmethod + def __sexp__(kls, value): + getattr(value, '__before_sexp__', lambda: None)() + + out = [kls.name_atom] + for f in fields(kls): + if f.type is SEXP_END: + break + out += sexp(f.type, getattr(value, f.name)) + yield out + + @staticmethod + def parse(kls, data, *args, **kwargs): + return kls.__map__(parse_sexp(data), *args, **kwargs) + + @staticmethod + def sexp(self): + return next(self.__sexp__(self)) + + +def sexp_type(name=None): + def register(cls): + cls = dataclass(cls) + cls.name_atom = Atom(name) if name is not None else None + for key in '__sexp__', '__map__', '__atoms__', 'parse': + if not hasattr(cls, key): + setattr(cls, key, classmethod(getattr(_SexpTemplate, key))) + + if not hasattr(cls, 'sexp'): + setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp')) + cls.positional = [] + cls.keys = {} + for f in fields(cls): + f_type = f.type + if f_type is SEXP_END: + break + + if hasattr(f_type, '__bind_field__'): + f_type.__bind_field__(f) + + atoms = getattr(f_type, '__atoms__', lambda: []) + atoms = list(atoms()) + for atom in atoms: + cls.keys[atom] = (f.name, f_type) + if not atoms: + cls.positional.append(f) + + return cls + return register + + diff --git a/gerbonara/cad/kicad/symbols.py b/gerbonara/cad/kicad/symbols.py new file mode 100644 index 0000000..de1d23d --- /dev/null +++ b/gerbonara/cad/kicad/symbols.py @@ -0,0 +1,446 @@ +""" +Library for processing KiCad's symbol files. +""" + +import json +import string +import math +import re +import sys +import itertools +from fnmatch import fnmatch +from collections import defaultdict +from dataclasses import field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from .sexp import * +from .sexp_mapper import * +from .base_types import * + + +PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free, + Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter, + Atom.no_connect) + + +PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low, + Atom.output_low, Atom.edge_clock_high, Atom.non_logic) + + +@sexp_type('alternate') +class AltFunction: + name: str = None + etype: PIN_ETYPE = Atom.unspecified + shape: PIN_STYLE = Atom.line + + +@sexp_type('__styled_text') +class StyledText: + value: str = None + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('pin') +class Pin: + etype: PIN_ETYPE = Atom.unspecified + style: PIN_STYLE = Atom.line + at: AtPos = field(default_factory=AtPos) + length: Named(float) = 2.54 + hide: Flag() = False + name: Rename(StyledText) = field(default_factory=StyledText) + number: Rename(StyledText) = field(default_factory=StyledText) + alternates: List(AltFunction) = field(default_factory=list) + + @property + def direction(self): + return {0: 'R', 90: 'U', 180: 'L', 270: 'D'}.get(self.at.rotation, 'R') + + @direction.setter + def direction(self, value): + self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()] + + +@sexp_type('fill') +class Fill: + type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none + + +@sexp_type('circle') +class Circle: + center: Rename(XYCoord) = field(default_factory=XYCoord) + radius: Named(float) = 0.0 + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + +@sexp_type('arc') +class Arc: + start: Rename(XYCoord) = field(default_factory=XYCoord) + mid: Rename(XYCoord) = field(default_factory=XYCoord) + end: Rename(XYCoord) = field(default_factory=XYCoord) + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + # TODO add function to calculate center, bounding box + + +@sexp_type('polyline') +class Polyline: + pts: PointList = field(default_factory=PointList) + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + @property + def points(self): + return self.pts.xy + + @points.setter + def points(self, value): + self.pts.xy = value + + @property + def closed(self): + # if the last and first point are the same, we consider the polyline closed + # a closed triangle will have 4 points (A-B-C-A) stored in the list of points + return len(self.points) > 3 and self.points[0] == self.points[-1] + + @property + def bbox(self): + if not self.points: + return (0.0, 0.0, 0.0, 0.0) + + return (min(p.x for p in self.points), + min(p.y for p in self.points), + max(p.x for p in self.points), + max(p.y for p in self.points)) + + def as_rectangle(self): + (maxx, maxy, minx, miny) = self.get_boundingbox() + return Rectangle( + minx, + maxy, + maxx, + miny, + self.stroke_width, + self.stroke_color, + self.fill_type, + self.fill_color, + unit=self.unit, + demorgan=self.demorgan, + ) + + def get_center_of_boundingbox(self): + (maxx, maxy, minx, miny) = self.get_boundingbox() + return ((minx + maxx) / 2, ((miny + maxy) / 2)) + + def is_rectangle(self): + # a rectangle has 5 points and is closed + if len(self.points) != 5 or not self.is_closed(): + return False + + # construct lines between the points + p0 = self.points[0] + for p1_idx in range(1, len(self.points)): + p1 = self.points[p1_idx] + dx = p1.x - p0.x + dy = p1.y - p0.y + if dx != 0 and dy != 0: + # if a line is neither horizontal or vertical its not + # part of a rectangle + return False + # select next point + p0 = p1 + + return True + + +@sexp_type('at') +class TextPos(XYCoord): + x: float = 0 # in millimeter + y: float = 0 # in millimeter + rotation: int = 0 # in degrees + + def __after_parse__(self, parent): + self.rotation = self.rotation / 10 + + def __before_sexp__(self): + self.rotation = round((self.rotation % 360) * 10) + + @property + def rotation_rad(self): + return math.radians(self.rotation) + + @rotation_rad.setter + def rotation_rad(self, value): + self.rotation = math.degrees(value) + + +@sexp_type('text') +class Text: + text: str = None + at: TextPos = field(default_factory=TextPos) + rotation: float = None + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('rectangle') +class Rectangle: + """ + Some v6 symbols use rectangles, newer ones encode them as polylines. + At some point in time we can most likely remove this class since its not used anymore + """ + + start: Rename(XYCoord) = None + end: Rename(XYCoord) = None + stroke: Stroke = field(default_factory=Stroke) + fill: Fill = field(default_factory=Fill) + + def as_polyline(self): + x1, y1 = self.start + x2, y2 = self.end + return Polyline([Point(x1, y1), Point(x2, y1), Point(x2, y2), Point(x1, y2), Point(x1, y1)], + self.stroke, self.fill) + + +@sexp_type('property') +class Property: + name: str = None + value: str = None + id: Named(int) = None + at: AtPos = field(default_factory=AtPos) + effects: TextEffect = field(default_factory=TextEffect) + + +@sexp_type('pin_numbers') +class PinNumberSpec: + hide: Flag() = False + + +@sexp_type('pin_names') +class PinNameSpec: + offset: OmitDefault(Named(float)) = 0.508 + hide: Flag() = False + + +@sexp_type('symbol') +class Unit: + name: str = None + circles: List(Circle) = field(default_factory=list) + arcs: List(Arc) = field(default_factory=list) + polylines: List(Polyline) = field(default_factory=list) + rectangles: List(Rectangle) = field(default_factory=list) + texts: List(Text) = field(default_factory=list) + pins: List(Pin) = field(default_factory=list) + unit_name: Named(str) = None + _ : SEXP_END = None + global_units: list = field(default_factory=list) + unit_global: Flag() = False + style_global: Flag() = False + demorgan_style: int = 1 + unit_index: int = 1 + symbol = None + + def __after_parse__(self, parent): + self.symbol = parent + + if not (m := re.fullmatch(r'(.*)_([0-9]+)_([0-9]+)', self.name)): + raise FormatError(f'Invalid unit name "{self.name}"') + sym_name, unit_index, demorgan_style = m.groups() + if sym_name != self.symbol.name: + raise FormatError(f'Unit name "{self.name}" does not match symbol name "{self.symbol.name}"') + self.demorgan_style = int(demorgan_style) + self.unit_index = int(unit_index) + self.style_global = self._demorgan_style == 0 + self.unit_global = self.unit_index == 0 + + def __before_sexp__(self): + self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}' + + def __getattr__(self, name): + if name.startswith('all_'): + name = name[4:] + return itertools.chain(getattr(self.global_units, name, []), getattr(self, name, [])) + + def pin_stacks(self): + stacks = defaultdict(lambda: set()) + for pin in self.all_pins(): + stacks[(pin.at.x, pin.at.y)].add(pin) + return stacks + + +@sexp_type('symbol') +class Symbol: + name: str = None + extends: Named(str) = None + power: Wrap(Flag()) = False + pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec) + pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec) + in_bom: Named(YesNoAtom()) = True + on_board: Named(YesNoAtom()) = True + properties: List(Property) = field(default_factory=list) + raw_units: List(Unit) = field(default_factory=list) + _ : SEXP_END = None + styles: {str: {str: Unit}} = None + global_units: {str: {str: Unit}} = None + library = None + + def __after_parse__(self, parent): + self.library = parent + + self.global_units = {} + self.styles = {} + + if self.extends: + self.in_bom = None + self.on_board = None + + self.properties = {prop.name: prop for prop in self.properties} + if (prop := self.properties.get('ki_fp_filters')): + prop.value = prop.value.split() if prop.value else [] + + for unit in self.raw_units: + if unit.unit_global or unit.style_global: + d = self.global_units.get(unit.demorgan_style, {}) + d[unit.name] = unit + self.global_units[unit.demorgan_style] = d + + for other in self.raw_units: + if other.unit_global or other.style_global or other == unit: + continue + if not (unit.unit_global or other.name == unit.name): + continue + if not (unit.style_global or other.demorgan_style == unit.demorgan_style): + continue + other.global_units.append(unit) + + else: + d = self.styles.get(unit.demorgan_style, {}) + d[unit.name] = unit + self.styles[unit.demorgan_style] = d + + def __before_sexp__(self): + self.raw_units = ([unit for style in self.global_units.values() for unit in style.values()] + + [unit for style in self.styles.values() for unit in style.values()]) + if (prop := self.properties.get('ki_fp_filters')): + if not isinstance(prop.value, str): + prop.value = ' '.join(prop.value) + self.properties = list(self.properties.values()) + + def default_properties(self): + for i, (name, value, hide) in enumerate([ + ('Reference', 'U', False), + ('Value', None, False), + ('Footprint', None, True), + ('Datasheet', None, True), + ('ki_locked', None, True), + ('ki_keywords', None, True), + ('ki_description', None, True), + ('ki_fp_filters', None, False), + ]): + self.properties[name] = Property(name=name, value=value, id=i, effects=TextEffect(hide=hide)) + + def units(self, demorgan_style=None): + if self.extends: + return self.library[self.extends].units(demorgan_style) + else: + return self.styles.get(demorgan_style or 'default', {}) + + def get_center_rectangle(self, units): + # return a polyline for the requested unit that is a rectangle + # and is closest to the center + candidates = {} + # building a dict with floats as keys.. there needs to be a rule against that^^ + pl_rects = [i.as_polyline() for i in self.rectangles] + pl_rects.extend(pl for pl in self.polylines if pl.is_rectangle()) + for pl in pl_rects: + if pl.unit in units: + # extract the center, calculate the distance to origin + (x, y) = pl.get_center_of_boundingbox() + dist = math.sqrt(x * x + y * y) + candidates[dist] = pl + + if candidates: + # sort the list return the first (smallest) item + return candidates[sorted(candidates.keys())[0]] + return None + + def is_graphic_symbol(self): + return self.extends is None and ( + not self.pins or self.get_property("Reference").value == "#SYM" + ) + + def pins_by_name(self, demorgan_style=None): + pins = defaultdict(lambda: set()) + for unit in self.units(demorgan_style): + for pin in unit.all_pins: + pins[pin.name].add(pin) + return pins + + def pins_by_number(self, demorgan_style=None): + pins = defaultdict(lambda: set()) + for unit in self.units(demorgan_style): + for pin in unit.all_pins: + pins[pin.number].add(pin) + return pins + + def __getattr__(self, name): + if name.startswith('all_'): + return itertools.chain(getattr(unit, name) for unit in self.raw_units) + + def filter_pins(self, name=None, direction=None, electrical_type=None): + for pin in self.all_pins: + if name and not fnmatch(pin.name, name): + continue + if direction and not pin.direction in direction: + continue + if electrical_type and not pin.etype in electical_type: + continue + yield pin + + def heuristically_small(self): + """ Heuristically try to determine whether this is a "small" component like a resistor, capacitor, LED, diode, + or transistor etc. When we have at most two pins, or there is no filled rectangle as symbol outline and we have + 3 or 4 pins, we assume this is a small symbol. + """ + if len(self.all_pins) <= 2: + return True + if len(self.all_pins) > 4: + return False + return bool(self.get_center_rectangle(range(self.unit_count))) + + +SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914] +@sexp_type('kicad_symbol_lib') +class Library: + _version: Named(int, name='version') = 20211014 + generator: Named(Atom) = Atom.kicad_library_utils + symbols: List(Symbol) = field(default_factory=list) + _ : SEXP_END = None + original_filename: str = None + + @property + def version(self): + return self._version + + @version.setter + def version(self, value): + if value not in SUPPORTED_FILE_FORMAT_VERSIONS: + raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') + + @classmethod + def open(cls, filename: str): + with open(filename) as f: + return cls.parse(f.read()) + + def write(self, filename=None): + with open(filename or self.original_filename, 'w') as f: + f.write(build_sexp(sexp(self))) + + +if __name__ == "__main__": + if len(sys.argv) >= 2: + a = Library.open(sys.argv[1]) + print(build_sexp(sexp(a))) + else: + print("pass a .kicad_sym file please") diff --git a/src/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py similarity index 68% rename from src/gerbonara/cad/primitives.py rename to gerbonara/cad/primitives.py index f581c38..9a9a373 100644 --- a/src/gerbonara/cad/primitives.py +++ b/gerbonara/cad/primitives.py @@ -4,7 +4,7 @@ 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 @@ -14,9 +14,6 @@ from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAper 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'): @@ -118,7 +115,7 @@ 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): @@ -145,56 +142,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 +177,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 +188,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') + 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, cache=cache) for target, source in [ (layer_stack[top, 'copper'], self.top_copper), @@ -262,6 +229,7 @@ class Graphics(Positioned): self.bottom_paste, self.drill_npth, self.drill_pth, + self.objects, ))), unit(self.x, self.unit), unit(self.y, self.unit)) else: return super().bounding_box(unit) @@ -274,30 +242,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 @@ -309,7 +253,7 @@ class Text(Positioned): polarity_dark: bool = True def render(self, layer_stack, cache=None): - obj_x, obj_y, rotation, flip = self.abs_pos + obj_x, obj_y, rotation = self.abs_pos global newstroke_font if newstroke_font is None: @@ -322,7 +266,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 +276,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,10 +295,10 @@ 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) + layer_stack[self.side, self.layer].objects.append(obj) def bounding_box(self, unit=MM): approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width @@ -369,208 +312,165 @@ class Text(Positioned): x0 = -approx_w if self.v_align == 'top': - y0 = 0 + y0 = -approx_h elif self.v_align == 'middle': y0 = -approx_h/2 elif self.v_align == 'bottom': - y0 = -approx_h + y0 = 0 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 +class Pad(Positioned): + pass -@dataclass(frozen=True, slots=True) -class PadStack: - _: KW_ONLY - unit: LengthUnit = MM +@dataclass +class SMDPad(Pad): + copper_aperture: Aperture + mask_aperture: Aperture + paste_aperture: Aperture + silk_features: list = field(default_factory=list) - @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') + def render(self, layer_stack, cache=None): + 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, 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) + 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, 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) + 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(frozen=True, slots=True) -class MechanicalHoleStack(PadStack): +@dataclass +class THTPad(Pad): 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 + pad_top: SMDPad + pad_bottom: SMDPad = None + aperture_inner: Aperture = None 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) + import sys + self.pad_bottom = copy(self.pad_top) + self.pad_bottom.flip() - if self.pad_top.flip: - raise ValueError('top pad cannot be flipped') + self.pad_top.parent = self.pad_bottom.parent = self - @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 + 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, cache=None): - x, y, rotation, flip = self.abs_pos - self.pad_stack.render(layer_stack, x, y, rotation, flip) + 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 False @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) + 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 = ObroundAperture(w, h, unit=unit) + ap_m = ObroundAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit) + ap_p = ObroundAperture(w, h, 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 Pad(Positioned): - pad_stack: PadStack +class Hole(Positioned): + diameter: float + mask_copper_margin: float = 0.2 def render(self, layer_stack, cache=None): - x, y, rotation, flip = self.abs_pos - self.pad_stack.render(layer_stack, x, y, rotation, flip) + 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, cache=None): + 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 self.pad_stack.single_sided + return False @dataclass @@ -580,9 +480,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 +629,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) @@ -750,7 +649,7 @@ 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()) + 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..6984ed6 --- /dev/null +++ b/gerbonara/cad/protoboard.py @@ -0,0 +1,739 @@ + +import sys +import re +import math +import string +import itertools +from copy import copy, deepcopy +import warnings +import importlib.resources + +from ..utils import MM, rotate_point +from .primitives import * +from ..graphic_objects import Region +from ..apertures import RectangleAperture, CircleAperture, ApertureMacroInstance +from ..aperture_macros.parse import ApertureMacro, VariableExpression +from ..aperture_macros import primitive as amp +from .kicad import footprints as kfp +from . import data as package_data + + +class ProtoBoard(Board): + def __init__(self, w, h, content, margin=None, corner_radius=None, mounting_hole_dia=None, mounting_hole_offset=None, unit=MM): + corner_radius = corner_radius or unit(1.5, MM) + super().__init__(w, h, corner_radius, unit=unit) + self.margin = margin or unit(2, MM) + self.content = content + + if mounting_hole_dia: + mounting_hole_offset = mounting_hole_offset or mounting_hole_dia*2 + ko = mounting_hole_offset*2 + + self.add(Hole(mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit)) + self.add(Hole(w-mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit)) + self.add(Hole(mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit)) + self.add(Hole(w-mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit)) + + self.keepouts.append(((0, 0), (ko, ko))) + self.keepouts.append(((w-ko, 0), (w, ko))) + self.keepouts.append(((0, h-ko), (ko, h))) + self.keepouts.append(((w-ko, h-ko), (w, h))) + + self.generate() + + def generate(self, unit=MM): + bbox = ((self.margin, self.margin), (self.w-self.margin, self.h-self.margin)) + bbox = unit.convert_bounds_from(self.unit, bbox) + for obj in self.content.generate(bbox, (True, True, True, True), unit): + self.add(obj, keepout_errors='skip') + + +class PropLayout: + def __init__(self, content, direction, proportions): + self.content = list(content) + if direction not in ('h', 'v'): + raise ValueError('direction must be one of "h", or "v".') + self.direction = direction + self.proportions = list(proportions) + if len(content) != len(proportions): + raise ValueError('proportions and content must have same length') + + def generate(self, bbox, border_text, unit=MM): + for i, (bbox, child) in enumerate(self.layout_2d(bbox, unit)): + first = bool(i == 0) + last = bool(i == len(self.content)-1) + yield from child.generate(bbox, ( + border_text[0] and (last or self.direction == 'h'), + border_text[1] and (last or self.direction == 'v'), + border_text[2] and (first or self.direction == 'h'), + border_text[3] and (first or self.direction == 'v'), + ), unit) + + def fit_size(self, w, h, unit=MM): + widths = [] + heights = [] + for ((x_min, y_min), (x_max, y_max)), child in self.layout_2d(((0, 0), (w, h)), unit): + if not isinstance(child, EmptyProtoArea): + widths.append(x_max - x_min) + heights.append(y_max - y_min) + if self.direction == 'h': + return sum(widths), max(heights, default=0) + else: + return max(widths, default=0), sum(heights) + + def layout_2d(self, bbox, unit=MM): + (x, y), (w, h) = bbox + w, h = w-x, h-y + + actual_l = 0 + target_l = 0 + + for l, child in zip(self.layout(w if self.direction == 'h' else h, unit), self.content): + this_x, this_y = x, y + this_w, this_h = w, h + target_l += l + + if self.direction == 'h': + this_w = target_l - actual_l + else: + this_h = target_l - actual_l + + this_w, this_h = child.fit_size(this_w, this_h, unit) + + if self.direction == 'h': + x += this_w + actual_l += this_w + this_h = h + else: + y += this_h + actual_l += this_h + this_w = w + + yield ((this_x, this_y), (this_x+this_w, this_y+this_h)), child + + def layout(self, length, unit=MM): + out = [ eval_value(value, MM(length, unit)) for value in self.proportions ] + total_length = sum(value for value in out if value is not None) + if length - total_length < -1e-6: + raise ValueError(f'Proportions sum to {total_length} mm, which is greater than the available space of {length} mm.') + + leftover = length - total_length + sum_props = sum( (value or 1.0) for value in self.proportions if not isinstance(value, str) ) + return [ unit(leftover * (value or 1.0) / sum_props if not isinstance(value, str) else calculated, MM) + for value, calculated in zip(self.proportions, out) ] + + @property + def single_sided(self): + return all(elem.single_sided for elem in self.content) + + def __str__(self): + children = ', '.join( f'{elem}:{width}' for elem, width in zip(self.content, self.proportions)) + return f'PropLayout[{self.direction.upper()}]({children})' + + +class TwoSideLayout: + def __init__(self, top, bottom): + self.top, self.bottom = top, bottom + + if not top.single_sided or not bottom.single_sided: + warnings.warn('Two-sided pattern used on one side of a TwoSideLayout') + + def fit_size(self, w, h, unit=MM): + w1, h1 = self.top.fit_size(w, h, unit) + w2, h2 = self.bottom.fit_size(w, h, unit) + if isinstance(self.top, EmptyProtoArea): + if isinstance(self.bottom, EmptyProtoArea): + return w1, h1 + return w2, h2 + if isinstance(self.bottom, EmptyProtoArea): + return w1, h1 + return max(w1, w2), max(h1, h2) + + def generate(self, bbox, border_text, unit=MM): + yield from self.top.generate(bbox, border_text, unit) + for obj in self.bottom.generate(bbox, border_text, unit): + obj.side = 'bottom' + yield obj + + +def numeric(start=1): + def gen(): + nonlocal start + for i in itertools.count(start): + yield str(i) + + return gen + + +def alphabetic(case='upper'): + if case not in ('lower', 'upper'): + raise ValueError('case must be one of "lower" or "upper".') + + index = string.ascii_lowercase if case == 'lower' else string.ascii_uppercase + + def gen(): + nonlocal index + + for i in itertools.count(): + if i<26: + yield index[i] + continue + + i -= 26 + if i<26*26: + yield index[i//26] + index[i%26] + continue + + i -= 26*26 + if i<26*26*26: + yield index[i//(26*26)] + index[(i//26)%26] + index[i%26] + + else: + raise ValueError('row/column index out of range') + + return gen + + +class PatternProtoArea: + def __init__(self, pitch_x, pitch_y=None, obj=None, numbers=True, font_size=None, font_stroke=None, number_x_gen=alphabetic(), number_y_gen=numeric(), interval_x=5, interval_y=None, margin=0, unit=MM): + self.pitch_x = pitch_x + self.pitch_y = pitch_y or pitch_x + self.margin = margin + self.obj = obj + self.unit = unit + self.numbers = numbers + self.font_size = font_size or unit(1.0, MM) + self.font_stroke = font_stroke or unit(0.2, MM) + self.interval_x = interval_x + self.interval_y = interval_y or (1 if MM(self.pitch_y, unit) >= 2.0 else 5) + self.number_x_gen, self.number_y_gen = number_x_gen, number_y_gen + + def fit_size(self, w, h, unit=MM): + (min_x, min_y), (max_x, max_y) = self.fit_rect(((0, 0), (max(0, w-2*self.margin), max(0, h-2*self.margin)))) + return max_x-min_x + 2*self.margin, max_y-min_y + 2*self.margin + + def fit_rect(self, bbox, unit=MM): + (x, y), (w, h) = bbox + x, y = x+self.margin, y+self.margin + w, h = w-x-self.margin, h-y-self.margin + + w_mod = round((w + 5e-7) % unit(self.pitch_x, self.unit), 6) + h_mod = round((h + 5e-7) % unit(self.pitch_y, self.unit), 6) + w_fit, h_fit = round(w - w_mod, 6), round(h - h_mod, 6) + + x = x + (w-w_fit)/2 + y = y + (h-h_fit)/2 + return (x, y), (x+w_fit, y+h_fit) + + def generate(self, bbox, border_text, unit=MM): + (x, y), (w, h) = bbox + w, h = w-x, h-y + + n_x = int(w//unit(self.pitch_x, self.unit)) + n_y = int(h//unit(self.pitch_y, self.unit)) + off_x = (w % unit(self.pitch_x, self.unit)) / 2 + off_y = (h % unit(self.pitch_y, self.unit)) / 2 + + if self.numbers: + for i, lno_i in list(zip(range(n_y), self.number_y_gen())): + if i == 0 or i == n_y - 1 or (i+1) % self.interval_y == 0: + t_y = off_y + y + (n_y - 1 - i + 0.5) * self.pitch_y + + if border_text[3]: + t_x = x + off_x + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit) + if not self.single_sided: + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', side='bottom', unit=self.unit) + + if border_text[1]: + t_x = x + w - off_x + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit) + if not self.single_sided: + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', side='bottom', unit=self.unit) + + for i, lno_i in zip(range(n_x), self.number_x_gen()): + if i == 0 or i == n_x - 1 or (i+1) % self.interval_x == 0: + t_x = off_x + x + (i + 0.5) * self.pitch_x + + if border_text[2]: + t_y = y + off_y + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit) + if not self.single_sided: + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', side='bottom', unit=self.unit) + + if border_text[0]: + t_y = y + h - off_y + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit) + if not self.single_sided: + yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', side='bottom', unit=self.unit) + + + for i in range(n_x): + for j in range(n_y): + if hasattr(self.obj, 'inst'): + inst = self.obj.inst(i, j, i == n_x-1, j == n_y-1) + if not inst: + continue + else: + inst = copy(self.obj) + + inst.x = inst.unit(off_x + x, unit) + (i + 0.5) * inst.unit(self.pitch_x, self.unit) + inst.y = inst.unit(off_y + y, unit) + (j + 0.5) * inst.unit(self.pitch_y, self.unit) + yield inst + + @property + def single_sided(self): + return self.obj.single_sided + + +class EmptyProtoArea: + def __init__(self, copper_fill=False): + self.copper_fill = copper_fill + + def fit_size(self, w, h, unit=MM): + return w, h + + def generate(self, bbox, border_text, unit=MM): + if self.copper_fill: + (min_x, min_y), (max_x, max_y) = bbox + group = ObjectGroup(0, 0, top_copper=[Region([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)], + unit=unit, polarity_dark=True)]) + group.bounding_box = lambda *args, **kwargs: None + yield group + + @property + def single_sided(self): + return True + + +class ManhattanPads(ObjectGroup): + def __init__(self, w, h=None, gap=0.2, unit=MM): + super().__init__(0, 0) + h = h or w + self.gap = gap + self.unit = unit + + p = (w-2*gap)/2 + q = (h-2*gap)/2 + small_ap = RectangleAperture(p, q, unit=unit) + + s = min(w, h) / 2 / math.sqrt(2) + large_ap = RectangleAperture(s, s, unit=unit).rotated(math.pi/4) + large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, unit=unit).rotated(math.pi/4) + + a = gap/2 + p/2 + b = gap/2 + q/2 + + self.top_copper.append(Flash(-a, -b, aperture=small_ap, unit=unit)) + self.top_copper.append(Flash(-a, b, aperture=small_ap, unit=unit)) + self.top_copper.append(Flash( a, -b, aperture=small_ap, unit=unit)) + self.top_copper.append(Flash( a, b, aperture=small_ap, unit=unit)) + self.top_copper.append(Flash(0, 0, aperture=large_ap_neg, polarity_dark=False, unit=unit)) + self.top_copper.append(Flash(0, 0, aperture=large_ap, unit=unit)) + self.top_mask = self.top_copper + + +class RFGroundProto(ObjectGroup): + def __init__(self, pitch=None, drill=None, clearance=None, via_dia=None, via_drill=None, pad_dia=None, trace_width=None, unit=MM): + super().__init__(0, 0) + self.unit = unit + self.pitch = pitch = pitch or unit(2.54, MM) + self.drill = drill = drill or unit(0.9, MM) + self.clearance = clearance = clearance or unit(0.3, MM) + self.via_drill = via_drill = via_drill or unit(0.4, MM) + self.via_dia = via_dia = via_dia or unit(0.8, MM) + + if pad_dia is None: + self.trace_width = trace_width = trace_width or unit(0.3, MM) + pad_dia = pitch - trace_width - 2*clearance + elif trace_width is None: + trace_width = pitch - pad_dia - 2*clearance + self.pad_dia = pad_dia + + via_ap = RectangleAperture(via_dia, via_dia, unit=unit).rotated(math.pi/4) + pad_ap = CircleAperture(pad_dia, unit=unit) + pad_neg_ap = CircleAperture(pad_dia+2*clearance, unit=unit) + ground_ap = RectangleAperture(pitch + unit(0.01, MM), pitch + unit(0.01, MM), unit=unit) + pad_drill = ExcellonTool(drill, plated=True, unit=unit) + via_drill = ExcellonTool(via_drill, plated=True, unit=unit) + + self.top_copper.append(Flash(0, 0, aperture=ground_ap, unit=unit)) + self.top_copper.append(Flash(0, 0, aperture=pad_neg_ap, polarity_dark=False, unit=unit)) + self.top_copper.append(Flash(0, 0, aperture=pad_ap, unit=unit)) + self.top_mask.append(Flash(0, 0, aperture=pad_ap, unit=unit)) + self.top_copper.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit)) + self.top_mask.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit)) + self.drill_pth.append(Flash(0, 0, aperture=pad_drill, unit=unit)) + self.drill_pth.append(Flash(pitch/2, pitch/2, aperture=via_drill, unit=unit)) + + self.bottom_copper = self.top_copper + self.bottom_mask = self.top_mask + + def inst(self, x, y, border_x, border_y): + inst = copy(self) + if border_x or border_y: + inst.drill_pth = inst.drill_pth[:-1] + inst.top_copper = inst.bottom_copper = inst.top_copper[:-1] + inst.top_mask = inst.bottom_mask = inst.top_mask[:-1] + return inst + + +class THTFlowerProto(ObjectGroup): + def __init__(self, pitch=None, drill=None, diameter=None, unit=MM): + super().__init__(0, 0, unit=unit) + self.pitch = pitch = pitch or unit(2.54, MM) + drill = drill or unit(0.9, MM) + diameter = diameter or unit(2.0, MM) + + p = pitch / 2 + self.objects.append(THTPad.circle(-p, 0, drill, diameter, paste=False, unit=unit)) + self.objects.append(THTPad.circle( p, 0, drill, diameter, paste=False, unit=unit)) + self.objects.append(THTPad.circle(0, -p, drill, diameter, paste=False, unit=unit)) + self.objects.append(THTPad.circle(0, p, drill, diameter, paste=False, unit=unit)) + + middle_ap = CircleAperture(diameter, unit=unit) + self.top_copper.append(Flash(0, 0, aperture=middle_ap, unit=unit)) + self.bottom_copper = self.top_mask = self.bottom_mask = self.top_copper + + def inst(self, x, y, border_x, border_y): + if (x % 2 == 0) and (y % 2 == 0): + return copy(self) + + if (x % 2 == 1) and (y % 2 == 1): + return copy(self) + + return None + + def bounding_box(self, unit=MM): + x, y, rotation = self.abs_pos + p = self.pitch/2 + return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p))) + +class PoweredProto(ObjectGroup): + """ Cell primitive for "powered" THT breadboards. This cell type is based on regular THT pads in a 100 mil grid, but + adds small SMD pads diagonally between the THT pads. These SMD pads are interconnected with traces and vias in such + a way that every second one is inter-linked, forming two fully connected grids. Next to every THT pad you have one + pad of each grid, so this layout is awesome for distributing power throughout the board. + + This design is based on one that Yajima Manufacturing Akizuki Denshi, Akihabara's finest electronics store sells for + next to nothing. Sadly, they don't ship internationally and they don't even have an English website, but if you ever + are in Akihabara, Tokyo, Japan I can *highly* recommend a visit. The ones Yajima make are better than what this will + produce since the Yajima ones use a two-colored silkscreen to visually distinguish the two power pad grids. + + Links: + Akizuki Denshi product page: https://akizukidenshi.com/catalog/g/gP-07214/ + Yajima Manufacturing Corporation website: http://www.yajima-works.co.jp/index.html + """ + + def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM): + super().__init__(0, 0) + self.unit = unit + self.pitch = pitch = pitch or unit(2.54, MM) + self.drill = drill = drill or unit(0.9, MM) + self.clearance = clearance = clearance or unit(0.3, MM) + self.trace_width = trace_width = trace_width or unit(0.3, MM) + self.via_size = via_size = via_size or unit(0.4, MM) + + main_pad_dia = pitch - trace_width - 2*clearance + power_pad_dia_max = math.sqrt(2)*pitch - main_pad_dia - 2*clearance + if power_pad_dia is None: + power_pad_dia = power_pad_dia_max - clearance # reduce some more to give the user more room + elif power_pad_dia > power_pad_dia_max: + warnings.warn(f'Power pad diameter {power_pad_dia} > {power_pad_dia_max} violates pad-to-pad clearance') + self.power_pad_dia = power_pad_dia + + main_ap = CircleAperture(main_pad_dia, unit=unit) + power_ap = CircleAperture(self.power_pad_dia, unit=unit) + + for l in [self.top_copper, self.bottom_copper]: + l.append(Flash(0, 0, aperture=main_ap, unit=unit)) + + l.append(Flash(-pitch/2, -pitch/2, aperture=power_ap, unit=unit)) + l.append(Flash(-pitch/2, pitch/2, aperture=power_ap, unit=unit)) + l.append(Flash( pitch/2, -pitch/2, aperture=power_ap, unit=unit)) + l.append(Flash( pitch/2, pitch/2, aperture=power_ap, unit=unit)) + + self.drill_pth.append(Flash(0, 0, ExcellonTool(drill, plated=True, unit=unit), unit=unit)) + self.drill_pth.append(Flash(-pitch/2, -pitch/2, ExcellonTool(via_size, plated=True, unit=unit), unit=unit)) + + self.top_mask = copy(self.top_copper) + self.bottom_mask = copy(self.bottom_copper) + + self.line_ap = CircleAperture(trace_width, unit=unit) + self.top_copper.append(Line(-pitch/2, -pitch/2, -pitch/2, pitch/2, aperture=self.line_ap, unit=unit)) + self.top_copper.append(Line(pitch/2, -pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit)) + self.bottom_copper.append(Line(-pitch/2, -pitch/2, pitch/2, -pitch/2, aperture=self.line_ap, unit=unit)) + self.bottom_copper.append(Line(-pitch/2, pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit)) + + def inst(self, x, y, border_x, border_y): + inst = copy(self) + if (x + y) % 2 == 0: + inst.drill_pth = inst.drill_pth[:-1] + + c = self.power_pad_dia/2 + self.clearance + p = self.pitch/2 + + if x == 1: + inst.top_silk = [Line(-p, -p+c, -p, p-c, aperture=self.line_ap, unit=self.unit)] + elif x % 2 == 0: + inst.top_silk = [Line(p, -p+c, p, p-c, aperture=self.line_ap, unit=self.unit)] + + if y == 0: + inst.bottom_silk = [Line(-p+c, -p, p-c, -p, aperture=self.line_ap, unit=self.unit)] + elif y % 2 == 1: + inst.bottom_silk = [Line(-p+c, p, p-c, p, aperture=self.line_ap, unit=self.unit)] + + return inst + + def bounding_box(self, unit=MM): + x, y, rotation = self.abs_pos + p = self.pitch/2 + return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p))) + + +class SpikyProto(ObjectGroup): + """ Cell primitive for the "spiky" protoboard designed by @electroniceel and published on github at the URL below. + This layout has small-ish standard THT pads, but in between these pads it puts a grid of SMD pads that are designed + for easy solder bridging to allow for the construction of traces from solder bridging. + + Github URL: https://github.com/electroniceel/protoboard + """ + + def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM): + super().__init__(0, 0, unit=unit) + res = importlib.resources.files(package_data) + + self.fp_center = kfp.Footprint.load(res.joinpath('center-pad-spikes.kicad_mod').read_text(encoding='utf-8')) + self.corner_pad = kfp.FootprintInstance(1.27, 1.27, self.fp_center, unit=MM) + + self.pad = kfp.Footprint.load(res.joinpath('tht-0.8.kicad_mod').read_text(encoding='utf-8')) + self.center_pad = kfp.FootprintInstance(0, 0, self.pad, unit=MM) + + self.fp_between = kfp.Footprint.load(res.joinpath('pad-between-spiked.kicad_mod').read_text(encoding='utf-8')) + self.right_pad = kfp.FootprintInstance(1.27, 0, self.fp_between, unit=MM) + self.top_pad = kfp.FootprintInstance(0, 1.27, self.fp_between, rotation=math.pi/2, unit=MM) + + @property + def objects(self): + return [x for x in (self.center_pad, self.corner_pad, self.right_pad, self.top_pad) if x is not None] + + @objects.setter + def objects(self, value): + pass + + def inst(self, x, y, border_x, border_y): + inst = copy(self) + + if border_x: + inst.corner_pad = inst.right_pad = None + + if border_y: + inst.corner_pad = inst.top_pad = None + + return inst + + +class AlioCell(ObjectGroup): + """ Cell primitive for the ALio protoboard designed by arief ibrahim adha and published on hackaday.io at the URL + below. Similar to electroniceel's spiky protoboard, this layout has small-ish standard THT pads, but in between + these pads it puts a grid of SMD pads that are designed for easy solder bridging to allow for the construction of + traces from solder bridging. + + Hackaday.io URL: https://hackaday.io/project/28570/ + """ + + def __init__(self, pitch=None, drill=None, clearance=None, link_pad_width=None, link_trace_width=None, via_size=None, unit=MM): + super().__init__(0, 0, unit=unit) + self.pitch = pitch or unit(2.54, MM) + self.drill = drill or unit(0.9, MM) + self.clearance = clearance or unit(0.3, MM) + self.link_pad_width = link_pad_width or unit(1.1, MM) + self.link_trace_width = link_trace_width or unit(0.5, MM) + self.via_size = via_size or unit(0.4, MM) + self.border_x, self.border_y = False, False + self.inst_x, self.inst_y = None, None + + @property + def single_sided(self): + return False + + def inst(self, x, y, border_x, border_y): + inst = copy(self) + inst.border_x, inst.border_y = border_x, border_y + inst.inst_x, inst.inst_y = x, y + return inst + + def bounding_box(self, unit): + x, y, rotation = self.abs_pos + # FIXME hack + return self.unit.convert_bounds_to(unit, ((x-self.pitch/2, y-self.pitch/2), (x+self.pitch/2, y+self.pitch/2))) + + def render(self, layer_stack, cache=None): + x, y, rotation = self.abs_pos + def xf(fe): + fe = copy(fe) + fe.rotate(rotation) + fe.offset(x, y, self.unit) + return fe + + var = VariableExpression + # parameters: [1: total height = pad width, 2: pitch, 3: trace width, 4: corner radius, 5: rotation, 6: clearance] + alio_main_macro = ApertureMacro('ALIOM', ( + amp.CenterLine(MM, 1, var(2)-var(6), var(2)-var(3)-2*var(6), 0, 0, var(5)), + amp.Outline(MM, 0, 5, ( + -var(2)/2, -var(2)/2, + -var(2)/2, -(var(7)-var(8)), + -var(7), -(var(7)-var(8)), + -(var(7)-var(8)), -var(7), + -(var(7)-var(8)), -var(2)/2, + -var(2)/2, -var(2)/2, + ), var(5)), + amp.Outline(MM, 0, 5, ( + -var(2)/2, var(2)/2, + -var(2)/2, (var(7)-var(8)), + -var(7), (var(7)-var(8)), + -(var(7)-var(8)), var(7), + -(var(7)-var(8)), var(2)/2, + -var(2)/2, var(2)/2, + ), var(5)), + amp.Outline(MM, 0, 5, ( + var(2)/2, -var(2)/2, + var(2)/2, -(var(7)-var(8)), + var(7), -(var(7)-var(8)), + (var(7)-var(8)), -var(7), + (var(7)-var(8)), -var(2)/2, + var(2)/2, -var(2)/2, + ), var(5)), + amp.Outline(MM, 0, 5, ( + var(2)/2, var(2)/2, + var(2)/2, (var(7)-var(8)), + var(7), (var(7)-var(8)), + (var(7)-var(8)), var(7), + (var(7)-var(8)), var(2)/2, + var(2)/2, var(2)/2, + ), var(5)), + amp.Circle(MM, 0, 2*var(8), -var(7), -var(7), var(5)), + amp.Circle(MM, 0, 2*var(8), -var(7), var(7), var(5)), + amp.Circle(MM, 0, 2*var(8), var(7), -var(7), var(5)), + amp.Circle(MM, 0, 2*var(8), var(7), var(7), var(5)), + ), ( + None, # 1 + None, # 2 + None, # 3 + None, # 4 + None, # 5 + None, # 6 + var(2)/2 - var(1)/2 + var(4), # 7 + var(4)+var(6), # 8 + )) + corner_radius = (self.link_pad_width - self.link_trace_width)/3 + main_ap = ApertureMacroInstance(alio_main_macro, (self.link_pad_width, # 1 + self.pitch, # 2 + self.link_trace_width, # 3 + corner_radius, # 4 + rotation, # 5 + self.clearance), unit=MM) # 6 + main_ap_90 = ApertureMacroInstance(alio_main_macro, (self.link_pad_width, # 1 + self.pitch, # 2 + self.link_trace_width, # 3 + corner_radius, # 4 + rotation-90, # 5 + self.clearance), unit=MM) # 6 + main_drill = ExcellonTool(self.drill, plated=True, unit=self.unit) + via_drill = ExcellonTool(self.via_size, plated=True, unit=self.unit) + + # parameters: [1: total height = pad width, 2: total width, 3: trace width, 4: corner radius, 5: rotation] + alio_macro = ApertureMacro('ALIOP', ( + amp.CenterLine(MM, 1, var(1)-2*var(4), var(1), 0, 0, var(5)), + amp.CenterLine(MM, 1, var(1), var(1)-2*var(4), 0, 0, var(5)), + amp.Circle(MM, 1, 2*var(4), -var(1)/2+var(4), -var(1)/2+var(4), var(5)), + amp.Circle(MM, 1, 2*var(4), -var(1)/2+var(4), var(1)/2-var(4), var(5)), + amp.Circle(MM, 1, 2*var(4), var(1)/2-var(4), -var(1)/2+var(4), var(5)), + amp.Circle(MM, 1, 2*var(4), var(1)/2-var(4), var(1)/2-var(4), var(5)), + amp.CenterLine(MM, 1, var(2), var(3), -var(2)/2 + var(1)/2, 0, var(5)), + )) + alio_dark = ApertureMacroInstance(alio_macro, (self.link_pad_width, # 1 + self.pitch-self.clearance, # 2 + self.link_trace_width, # 3 + corner_radius, # 4 + rotation), unit=MM) # 5 + alio_dark_90 = ApertureMacroInstance(alio_macro, (self.link_pad_width, # 1 + self.pitch-self.clearance, # 2 + self.link_trace_width, # 3 + corner_radius, # 4 + rotation+90), unit=MM) # 5 + + # all layers are identical here + for side, use in (('top', 'copper'), ('top', 'mask'), ('bottom', 'copper'), ('bottom', 'mask')): + if side == 'top': + layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap, unit=self.unit))) + if not self.border_y: + layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark, unit=self.unit))) + else: + layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap_90, unit=self.unit))) + if not self.border_x: + layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark_90, unit=self.unit))) + + layer_stack.drill_pth.append(Flash(x, y, aperture=main_drill, unit=self.unit)) + if not (self.border_x or self.border_y): + layer_stack.drill_pth.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=via_drill, unit=self.unit))) + + +def convert_to_mm(value, unit): + unitl = unit.lower() + if unitl == 'mm': + return value + elif unitl == 'cm': + return value*10 + elif unitl == 'in': + return value*25.4 + elif unitl == 'mil': + return value/1000*25.4 + else: + raise ValueError(f'Invalid unit {unit}, allowed units are mm, cm, in, and mil.') + + +_VALUE_RE = re.compile('([0-9]*\.?[0-9]+)(cm|mm|in|mil|%)') +def eval_value(value, total_length=None): + if not isinstance(value, str): + return None + + m = _VALUE_RE.match(value.lower()) + number, unit = m.groups() + if unit == '%': + if total_length is None: + raise ValueError('Percentages are not allowed for this value') + return total_length * float(number) / 100 + return convert_to_mm(float(number), unit) + + +def _demo(): + #pattern1 = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False)) + #pattern1 = PatternProtoArea(2.54, 2.54, obj=SpikyProto()) + #pattern2 = PatternProtoArea(1.2, 2.0, obj=SMDPad.rect(0, 0, 1.0, 1.8, paste=False)) + #pattern3 = PatternProtoArea(2.54, 1.27, obj=SMDPad.rect(0, 0, 2.3, 1.0, paste=False)) + #pattern3 = EmptyProtoArea(copper_fill=True) + #stack = TwoSideLayout(pattern2, pattern3) + #pattern2 = PatternProtoArea(2.54, obj=PoweredProto(), margin=1) + pattern = PatternProtoArea(2.54, obj=RFGroundProto()) + #stack = PropLayout([pattern2, pattern3], 'h', [0.5, 0.5]) + #pattern = PropLayout([pattern1, stack], 'h', [0.5, 0.5]) + #pattern = PatternProtoArea(2.54, obj=ManhattanPads(2.54)) + #pattern = PatternProtoArea(2.54*1.5, obj=THTFlowerProto()) + #pattern = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False)) + #pattern = PatternProtoArea(2.54, obj=PoweredProto()) + #pattern = PatternProtoArea(2.54, obj=AlioCell(), margin=2) + pb = ProtoBoard(50, 47, pattern, mounting_hole_dia=3.2, mounting_hole_offset=5) + #pb = ProtoBoard(10, 10, pattern1) + print(pb.pretty_svg()) + pb.layer_stack().save_to_directory('/tmp/testdir') + + +if __name__ == '__main__': + _demo() + #cnt = alphabetic()() + #for _ in range(32): + # for _ in range(26): + # print(f'{next(cnt):>2}', end=' ', file=sys.stderr) + # print(file=sys.stderr) + diff --git a/src/gerbonara/cad/protoserve.py b/gerbonara/cad/protoserve.py similarity index 74% rename from src/gerbonara/cad/protoserve.py rename to gerbonara/cad/protoserve.py index 994bc20..25ef8c6 100644 --- a/src/gerbonara/cad/protoserve.py +++ b/gerbonara/cad/protoserve.py @@ -8,7 +8,6 @@ from quart import Quart, request, Response, send_file, abort from . import protoboard as pb from . import protoserve_data -from .primitives import SMDStack from ..utils import MM, Inch @@ -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 @@ -107,8 +106,7 @@ def deserialize(obj, unit): pitch = mil(float(obj.get('pitch', 2.54))) hole_dia = mil(float(obj['hole_dia'])) pattern_dia = mil(float(obj['pattern_dia'])) - clearance = mil(float(obj['clearance'])) - return pb.PatternProtoArea(pitch, pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, clearance, unit=unit), unit=unit) + return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit) case 'spiky': return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit) @@ -129,20 +127,6 @@ def deserialize(obj, unit): via_size=via_size ), margin=unit(1.5, MM), unit=unit) - case 'breadboard': - horizontal = obj.get('direction', 'v') == 'h' - drill = float(obj.get('hole_dia', 0.9)) - return pb.BreadboardArea(clearance=clearance, drill=drill, horizontal=horizontal, unit=unit) - - case 'starburst': - trace_width_x = float(obj.get('trace_width_x', 1.8)) - trace_width_y = float(obj.get('trace_width_y', 1.8)) - drill = float(obj.get('hole_dia', 0.9)) - annular_ring = float(obj.get('annular', 1.2)) - clearance = float(obj.get('clearance', 0.4)) - mask_width = float(obj.get('mask_width', 0.5)) - return pb.PatternProtoArea(pitch_x, pitch_y, pb.StarburstPad(pitch_x, pitch_y, trace_width_x, trace_width_y, clearance, mask_width, drill, annular_ring, unit=unit), unit=unit) - case 'rf': pitch = float(obj.get('pitch', 2.54)) hole_dia = float(obj['hole_dia']) @@ -155,7 +139,6 @@ 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))) @@ -172,14 +155,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 +172,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 87% rename from src/gerbonara/cad/protoserve_data/protoserve.html rename to gerbonara/cad/protoserve_data/protoserve.html index 4da027a..c42ce6c 100644 --- a/src/gerbonara/cad/protoserve_data/protoserve.html +++ b/gerbonara/cad/protoserve_data/protoserve.html @@ -177,14 +177,11 @@ input[type="text"]:focus:valid { 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; } @@ -283,12 +280,6 @@ input[type="text"]:focus:valid { inch - -
- Automatically generated top side preview image - Automatically generated bottom side preview image + Automatically generated preview image
@@ -480,7 +468,7 @@ input[type="text"]:focus:valid { mil