Pretty SVG WIP

This commit is contained in:
jaseg 2022-07-03 21:35:20 +02:00
parent b75404efce
commit f558f66bc0
4 changed files with 176 additions and 156 deletions

View file

@ -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
# 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.
import os
import argparse
from .render import available_renderers
from .render import theme
from .pcb import PCB
from . import load_layer
from .layers import LayerStack
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.'
# )
@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.')
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()

View file

@ -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)

View file

@ -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

View file

@ -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