summaryrefslogtreecommitdiff
path: root/gerbonara
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2022-07-03 21:35:20 +0200
committerjaseg <git@jaseg.de>2022-07-03 21:35:20 +0200
commitf558f66bc0cf90f587b346697e1fe03f03d5214f (patch)
tree16a4d63107cc85a1edcdf894483a24f077fbc856 /gerbonara
parentb75404efcee92aa68c7eaad85b018174f0902fa0 (diff)
downloadgerbonara-f558f66bc0cf90f587b346697e1fe03f03d5214f.tar.gz
gerbonara-f558f66bc0cf90f587b346697e1fe03f03d5214f.tar.bz2
gerbonara-f558f66bc0cf90f587b346697e1fe03f03d5214f.zip
Pretty SVG WIP
Diffstat (limited to 'gerbonara')
-rw-r--r--gerbonara/__main__.py129
-rw-r--r--gerbonara/graphic_objects.py57
-rw-r--r--gerbonara/graphic_primitives.py23
-rw-r--r--gerbonara/layers.py123
4 files changed, 176 insertions, 156 deletions
diff --git a/gerbonara/__main__.py b/gerbonara/__main__.py
index 988adff..57b3737 100644
--- a/gerbonara/__main__.py
+++ b/gerbonara/__main__.py
@@ -1,122 +1,25 @@
-#! /usr/bin/env python
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
-# Copyright 2013-2014 Paulo Henrique Silva <ph.silva@gmail.com>
+import click
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
+from .layers import LayerStack
-# http://www.apache.org/licenses/LICENSE-2.0
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations under
-# the License.
+@click.command()
+@click.option('-t' ,'--top', help='Render board top side.', is_flag=True)
+@click.option('-b' ,'--bottom', help='Render board bottom side.', is_flag=True)
+@click.argument('gerber_dir_or_zip', type=click.Path(exists=True))
+@click.argument('output_svg', required=False, default='-', type=click.File('w'))
+def render(gerber_dir_or_zip, output_svg, top, bottom):
+ if (bool(top) + bool(bottom)) != 1:
+ raise click.UsageError('Excactly one of --top or --bottom must be given.')
-import os
-import argparse
-from .render import available_renderers
-from .render import theme
-from .pcb import PCB
-from . import load_layer
-
-
-def main():
- parser = argparse.ArgumentParser(
- description='Render gerber files to image',
- prog='gerber-render'
- )
- parser.add_argument(
- 'filenames', metavar='FILENAME', type=str, nargs='+',
- help='Gerber files to render. If a directory is provided, it should '
- 'be provided alone and should contain the gerber files for a '
- 'single PCB.'
- )
- parser.add_argument(
- '--outfile', '-o', type=str, nargs='?', default='out',
- help="Output Filename (extension will be added automatically)"
- )
- parser.add_argument(
- '--backend', '-b', choices=available_renderers.keys(), default='cairo',
- help='Choose the backend to use to generate the output.'
- )
- parser.add_argument(
- '--theme', '-t', choices=theme.THEMES.keys(), default='default',
- help='Select render theme.'
- )
- parser.add_argument(
- '--width', type=int, default=1920, help='Maximum width.'
- )
- parser.add_argument(
- '--height', type=int, default=1080, help='Maximum height.'
- )
- parser.add_argument(
- '--verbose', '-v', action='store_true', default=False,
- help='Increase verbosity of the output.'
- )
- # parser.add_argument(
- # '--quick', '-q', action='store_true', default=False,
- # help='Skip longer running rendering steps to produce lower quality'
- # ' output faster. This only has an effect for the freecad backend.'
- # )
- # parser.add_argument(
- # '--nox', action='store_true', default=False,
- # help='Run without using any GUI elements. This may produce suboptimal'
- # 'output. For the freecad backend, colors, transparancy, and '
- # 'visibility cannot be set without a GUI instance.'
- # )
-
- args = parser.parse_args()
-
- renderer = available_renderers[args.backend]()
-
- if args.backend in ['cairo', ]:
- outext = 'png'
- else:
- outext = None
-
- if os.path.exists(args.filenames[0]) and os.path.isdir(args.filenames[0]):
- directory = args.filenames[0]
- pcb = PCB.from_directory(directory)
-
- if args.backend in ['cairo', ]:
- top = pcb.top_layers
- bottom = pcb.bottom_layers
- copper = pcb.copper_layers
-
- outline = pcb.outline_layer
- if outline:
- top = [outline] + top
- bottom = [outline] + bottom
- copper = [outline] + copper + pcb.drill_layers
-
- renderer.render_layers(
- layers=top, theme=theme.THEMES[args.theme],
- max_height=args.height, max_width=args.width,
- filename='{0}.top.{1}'.format(args.outfile, outext)
- )
- renderer.render_layers(
- layers=bottom, theme=theme.THEMES[args.theme],
- max_height=args.height, max_width=args.width,
- filename='{0}.bottom.{1}'.format(args.outfile, outext)
- )
- renderer.render_layers(
- layers=copper, theme=theme.THEMES['Transparent Multilayer'],
- max_height=args.height, max_width=args.width,
- filename='{0}.copper.{1}'.format(args.outfile, outext))
- else:
- pass
- else:
- filenames = args.filenames
- for filename in filenames:
- layer = load_layer(filename)
- settings = theme.THEMES[args.theme].get(layer.layer_class, None)
- renderer.render_layer(layer, settings=settings)
- renderer.dump(filename='{0}.{1}'.format(args.outfile, outext))
+ stack = LayerStack.open(gerber_dir_or_zip, lazy=True)
+ print(f'Loaded {stack}')
+ svg = stack.to_pretty_svg(side=('top' if top else 'bottom'))
+ output_svg.write(str(svg))
if __name__ == '__main__':
- main()
+ render()
diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py
index 35c08f3..0e433cf 100644
--- a/gerbonara/graphic_objects.py
+++ b/gerbonara/graphic_objects.py
@@ -18,7 +18,7 @@
import math
import copy
-from dataclasses import dataclass, astuple, replace, field, fields
+from dataclasses import dataclass, astuple, field, fields
from .utils import MM, InterpMode, to_unit, rotate_point
from . import graphic_primitives as gp
@@ -258,56 +258,51 @@ class Region(GraphicObject):
There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a
cut-in, the region is allowed to touch (but never overlap!) itself.
-
- :attr poly: :py:class:`~.graphic_primitives.ArcPoly` describing the actual outline of this Region. The coordinates of
- this poly are in the unit of this instance's :py:attr:`unit` field.
"""
def __init__(self, outline=None, arc_centers=None, *, unit, polarity_dark):
self.unit = unit
self.polarity_dark = polarity_dark
- outline = [] if outline is None else outline
- arc_centers = [] if arc_centers is None else arc_centers
- self.poly = gp.ArcPoly(outline, arc_centers)
+ self.outline = [] if outline is None else outline
+ self.arc_centers = [] if arc_centers is None else arc_centers
def __len__(self):
- return len(self.poly)
+ return len(self.outline)
def __bool__(self):
- return bool(self.poly)
+ return bool(self.outline)
def _offset(self, dx, dy):
- self.poly.outline = [ (x+dx, y+dy) for x, y in self.poly.outline ]
+ self.outline = [ (x+dx, y+dy) for x, y in self.outline ]
def _rotate(self, angle, cx=0, cy=0):
- self.poly.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.poly.outline ]
- self.poly.arc_centers = [
+ self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
+ self.arc_centers = [
(arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
- for p, arc in zip(self.poly.outline, self.poly.arc_centers) ]
+ for p, arc in zip(self.outline, self.arc_centers) ]
def append(self, obj):
if obj.unit != self.unit:
obj = obj.converted(self.unit)
- if not self.poly.outline:
- self.poly.outline.append(obj.p1)
- self.poly.outline.append(obj.p2)
+ if not self.outline:
+ self.outline.append(obj.p1)
+ self.outline.append(obj.p2)
if isinstance(obj, Arc):
- self.poly.arc_centers.append((obj.clockwise, obj.center_relative))
+ self.arc_centers.append((obj.clockwise, obj.center_relative))
else:
- self.poly.arc_centers.append(None)
+ self.arc_centers.append(None)
def to_primitives(self, unit=None):
- self.poly.polarity_dark = self.polarity_dark # FIXME: is this the right spot to do this?
if unit == self.unit:
- yield self.poly
+ yield gp.ArcPoly(outline=self.outline, arc_centers=self.arc_centers, polarity_dark=self.polarity_dark)
else:
to = lambda value: self.unit.convert_to(unit, value)
- conv_outline = [ (to(x), to(y)) for x, y in self.poly.outline ]
+ conv_outline = [ (to(x), to(y)) for x, y in self.outline ]
convert_entry = lambda entry: (entry[0], (to(entry[1][0]), to(entry[1][1])))
- conv_arc = [ None if entry is None else convert_entry(entry) for entry in self.poly.arc_centers ]
+ conv_arc = [ None if entry is None else convert_entry(entry) for entry in self.arc_centers ]
yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark)
@@ -319,9 +314,9 @@ class Region(GraphicObject):
# TODO report gerbv issue upstream
yield gs.interpolation_mode_statement() + '*'
- yield from gs.set_current_point(self.poly.outline[0], unit=self.unit)
+ yield from gs.set_current_point(self.outline[0], unit=self.unit)
- for point, arc_center in zip(self.poly.outline[1:], self.poly.arc_centers):
+ for point, arc_center in zip(self.outline[1:], self.arc_centers):
if arc_center is None:
yield from gs.set_interpolation_mode(InterpMode.LINEAR)
@@ -410,10 +405,13 @@ class Line(GraphicObject):
"""
return self.tool.plated
- def to_primitives(self, unit=None):
+ def as_primitive(self, unit=None):
conv = self.converted(unit)
w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
- yield gp.Line(*conv.p1, *conv.p2, w, polarity_dark=self.polarity_dark)
+ return gp.Line(*conv.p1, *conv.p2, w, polarity_dark=self.polarity_dark)
+
+ def to_primitives(self, unit=None):
+ yield self.as_primitive(unit=unit)
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)
@@ -613,16 +611,19 @@ class Arc(GraphicObject):
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
self.cx, self.cy = new_cx - self.x1, new_cy - self.y1
- def to_primitives(self, unit=None):
+ def as_primitive(self, unit=None):
conv = self.converted(unit)
w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
- yield gp.Arc(x1=conv.x1, y1=conv.y1,
+ return gp.Arc(x1=conv.x1, y1=conv.y1,
x2=conv.x2, y2=conv.y2,
cx=conv.cx, cy=conv.cy,
clockwise=self.clockwise,
width=w,
polarity_dark=self.polarity_dark)
+ def to_primitives(self, unit=None):
+ yield self.as_primitive(unit=unit)
+
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)
yield from gs.set_aperture(self.aperture)
diff --git a/gerbonara/graphic_primitives.py b/gerbonara/graphic_primitives.py
index 072a98a..7dc1126 100644
--- a/gerbonara/graphic_primitives.py
+++ b/gerbonara/graphic_primitives.py
@@ -26,7 +26,7 @@ from .utils import *
prec = lambda x: f'{float(x):.6}'
-@dataclass
+@dataclass(frozen=True)
class GraphicPrimitive:
# hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY.
@@ -63,7 +63,7 @@ class GraphicPrimitive:
raise NotImplementedError()
-@dataclass
+@dataclass(frozen=True)
class Circle(GraphicPrimitive):
#: Center X coordinate
x : float
@@ -80,7 +80,7 @@ class Circle(GraphicPrimitive):
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), style=f'fill: {color}')
-@dataclass
+@dataclass(frozen=True)
class ArcPoly(GraphicPrimitive):
""" Polygon whose sides may be either straight lines or circular arcs. """
@@ -132,7 +132,7 @@ class ArcPoly(GraphicPrimitive):
""" Return ``True`` if this polygon has any outline points. """
return bool(len(self))
- def _path_d(self):
+ def path_d(self):
if len(self.outline) == 0:
return
@@ -147,10 +147,10 @@ class ArcPoly(GraphicPrimitive):
def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg
- return tag('path', d=' '.join(self._path_d()), style=f'fill: {color}')
+ return tag('path', d=' '.join(self.path_d()), style=f'fill: {color}')
-@dataclass
+@dataclass(frozen=True)
class Line(GraphicPrimitive):
""" Straight line with round end caps. """
#: Start X coordinate. As usual in modern graphics APIs, this is at the center of the half-circle capping off this
@@ -165,6 +165,9 @@ class Line(GraphicPrimitive):
#: Line width
width : float
+ def flip(self):
+ return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1)
+
@classmethod
def from_obround(kls, x:float, y:float, w:float, h:float, rotation:float=0, polarity_dark:bool=True):
""" Convert a gerber obround into a :py:class:`~.graphic_primitives.Line`. """
@@ -188,7 +191,7 @@ class Line(GraphicPrimitive):
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round')
-@dataclass
+@dataclass(frozen=True)
class Arc(GraphicPrimitive):
""" Circular arc with line width ``width`` going from ``(x1, y1)`` to ``(x2, y2)`` around center at ``(cx, cy)``. """
#: Start X coodinate
@@ -209,6 +212,10 @@ class Arc(GraphicPrimitive):
#: Line width of this arc.
width : float
+ def flip(self):
+ return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1,
+ cx=(self.x + self.cx) - self.x2, cy=(self.y + self.cy) - self.y2, clockwise=not self.clockwise)
+
def bounding_box(self):
r = self.width/2
endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
@@ -236,7 +243,7 @@ class Arc(GraphicPrimitive):
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}',
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round; fill: none')
-@dataclass
+@dataclass(frozen=True)
class Rectangle(GraphicPrimitive):
#: **Center** X coordinate
x : float
diff --git a/gerbonara/layers.py b/gerbonara/layers.py
index afd6db9..f53b63c 100644
--- a/gerbonara/layers.py
+++ b/gerbonara/layers.py
@@ -23,6 +23,8 @@ import sys
import re
import warnings
import copy
+import bisect
+import textwrap
import itertools
from collections import namedtuple
from pathlib import Path
@@ -35,6 +37,8 @@ from .ipc356 import Netlist
from .cam import FileSettings, LazyCamFile
from .layer_rules import MATCH_RULES
from .utils import sum_bounds, setup_svg, MM, Tag
+from . import graphic_objects as go
+from . import graphic_primitives as gp
STANDARD_LAYERS = [
@@ -49,6 +53,15 @@ STANDARD_LAYERS = [
'bottom paste',
]
+DEFAULT_COLORS = {
+ 'copper': '#cccccc',
+ 'mask': '#004200bf',
+ 'paste': '#999999',
+ 'silk': '#e0e0e0',
+ 'drill': '#303030',
+ 'outline': '#F0C000',
+ }
+
class NamingScheme:
kicad = {
'top copper': '{board_name}-F.Cu.gbr',
@@ -483,28 +496,64 @@ class LayerStack:
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor=bg, tag=tag)
- def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False):
+ def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False, colors=None):
+ if colors is None:
+ colors = DEFAULT_COLORS
+
+ colors_alpha = {}
+ for layer, color in colors.items():
+ if isinstance(color, str):
+ if re.match(r'#[0-9a-fA-F]{8}', color):
+ colors_alpha[layer] = (color[:-2], int(color[-2:], 16)/255)
+ else:
+ colors_alpha[layer] = (color, 1)
+ else:
+ colors_alpha[layer] = color
+
if force_bounds:
bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds)
else:
bounds = self.board_bounds(unit=svg_unit, default=((0, 0), (0, 0)))
- tags = []
+ filter_defs = []
+
+ for layer, (color, alpha) in colors_alpha.items():
+ filter_defs.append(textwrap.dedent(f'''
+ <filter id="f-{layer}">
+ <feFlood result="flood-black" flood-color="black" flood-opacity="1"/>
+ <feFlood result="flood-green" flood-color="{color}"/>
+ <feBlend in="SourceGraphic" in2="flood-black" result="overlay" mode="normal"/>
+ <feBlend in="overlay" in2="flood-green" result="colored" mode="multiply"/>
+ <feColorMatrix in="overlay" type="matrix" result="alphaOut" values="0 0 0 0 0
+ 0 0 0 0 0
+ 0 0 0 0 0
+ {alpha} 0 0 0 0"/>
+ <feComposite in="colored" in2="alphaOut" operator="in"/>
+ </filter>'''.strip()))
+
+ tags = [tag('defs', filter_defs)]
inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {}
- for use, color in {'copper': 'black', 'mask': 'blue', 'silk': 'red'}.items():
+ for use in ['copper', 'mask', 'silk', 'paste']:
if (side, use) not in self:
warnings.warn(f'Layer "{side} {use}" not found. Found layers: {", ".join(side + " " + use for side, use in self.graphic_layers)}')
continue
layer = self[(side, use)]
- tags.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg=color, bg="white", tag=Tag)),
- id=f'l-{side}-{use}', **inkscape_attrs(f'{side} {use}')))
+ fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white')
+ objects = list(layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, tag=Tag))
+ if use == 'mask':
+ objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), style='fill:white'))
+ tags.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})', **inkscape_attrs(f'{side} {use}')))
for i, layer in enumerate(self.drill_layers):
- tags.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='magenta', bg="white", tag=Tag)),
+ tags.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
id=f'l-drill-{i}', **inkscape_attrs(f'drill-{i}')))
+ if self.outline:
+ tags.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
+ id=f'l-outline-{i}', **inkscape_attrs(f'outline-{i}')))
+
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape)
def bounding_box(self, unit=MM, default=None):
@@ -635,7 +684,67 @@ class LayerStack:
@property
def outline(self):
return self['mechanical outline']
-
+
+ def outline_svg_d(self, tol=0.01, unit=MM):
+ chains = self.outline_polygons(tol, unit)
+ polys = []
+ for chain in chains:
+ outline = [ (chain[0].x1, chain[0].y1), *((elem.x2, elem.y2) for elem in chain) ]
+ arcs = [ (elem.clockwise, (elem.cx, elem.cy)) if isinstance(elem, gp.Arc) else None for elem in chain ]
+ poly = gp.ArcPoly(outline=outline, arc_centers=arcs)
+ polys.append(' '.join(poly.path_d()) + ' Z')
+ return ' '.join(polys)
+
+ def outline_polygons(self, tol=0.01, unit=MM):
+ polygons = []
+ lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
+
+ by_x = sorted([ (obj.x1, obj) for obj in lines ] + [ (obj.x2, obj) for obj in lines ], key=lambda x: x[0])
+ dist_sq = lambda x1, y1, x2, y2: (x2-x1)**2 + (y2-y1)**2
+
+ joins = {}
+ for cur in lines:
+ for i, (x, y) in enumerate([(cur.x1, cur.y1), (cur.x2, cur.y2)]):
+ x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol)
+ x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol)
+ selected = { elem for elem_x, elem in by_x[x_left:x_right] if elem != cur }
+
+ if not selected:
+ continue # loose end
+
+ nearest = sorted(selected, key=lambda elem: min(dist_sq(elem.x1, elem.y1, x, y), dist_sq(elem.x2, elem.y2, x, y)))[0]
+
+ d1, d2 = dist_sq(nearest.x1, nearest.y1, x, y), dist_sq(nearest.x2, nearest.y2, x, y)
+ j = 0 if d1 < d2 else 1
+
+ if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
+ raise ValueError(f'Error: three-way intersection of {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}')
+
+ if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
+ raise ValueError(f'Error: three-way intersection of {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}')
+
+ joins[(cur, i)] = (nearest, j)
+ joins[(nearest, j)] = (cur, i)
+
+ def flip_if(obj, i):
+ if i:
+ c = copy.copy(obj)
+ c.flip()
+ return c
+ else:
+ return obj
+
+ while joins:
+ (first, i), (cur, j) = joins.popitem()
+ del joins[(cur, j)]
+ l = [ flip_if(first, not i), flip_if(cur, j) ]
+ while cur != first and (cur, not j) in joins:
+ cur, j = joins.pop((cur, not j))
+ del joins[(cur, j)]
+ l.append(flip_if(cur, j))
+ yield l
+
+
def _merge_layer(self, target, source):
if source is None:
return