From f558f66bc0cf90f587b346697e1fe03f03d5214f Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 3 Jul 2022 21:35:20 +0200 Subject: Pretty SVG WIP --- gerbonara/__main__.py | 129 +++++----------------------------------- gerbonara/graphic_objects.py | 57 +++++++++--------- gerbonara/graphic_primitives.py | 23 ++++--- gerbonara/layers.py | 123 +++++++++++++++++++++++++++++++++++--- 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 +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''' + + + + + + + + '''.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 -- cgit