kopia lustrzana https://gitlab.com/gerbolyze/gerbonara
More doc!
rodzic
18b9da8660
commit
ac66fd9d6b
|
@ -4,9 +4,6 @@ Aperture Macros
|
|||
.. autoclass:: gerbonara.aperture_macros.parse.ApertureMacro
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.aperture_macros.parse.GenericMacros
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.aperture_macros.expression.Expression
|
||||
:members:
|
||||
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
Graphic Primitives
|
||||
==================
|
||||
|
||||
Graphic prmitives are the core of Gerbonara's rendering interface. Individual graphic objects such as a Gerber
|
||||
:py:class:`.Region` as well as entire layers such as a :py:class:`.GerberFile` can be rendered into a list of graphic
|
||||
primitives. This rendering step resolves aperture definitions, calculates out aperture macros, converts units into a
|
||||
given target unit, and maps complex shapes to a small number of subclasses of :py:class:`.GraphicPrimitive`.
|
||||
|
||||
All graphic primitives have a :py:attr:`~.GraphicPrimitive.polarity_dark` attribute. Its meaning is identical with
|
||||
:py:attr:`.GraphicObject.polarity_dark`.
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.GraphicPrimitive
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.Circle
|
||||
:members:
|
||||
The five types of Graphic Primitives
|
||||
------------------------------------
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.Obround
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.ArcPoly
|
||||
:members:
|
||||
Stroked lines
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.Line
|
||||
:members:
|
||||
|
@ -19,9 +24,15 @@ Graphic Primitives
|
|||
.. autoclass:: gerbonara.graphic_primitives.Arc
|
||||
:members:
|
||||
|
||||
Filled shapes
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.Circle
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.Rectangle
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.graphic_primitives.RegularPolygon
|
||||
.. autoclass:: gerbonara.graphic_primitives.ArcPoly
|
||||
:members:
|
||||
|
||||
|
|
|
@ -159,7 +159,7 @@ class Polygon(Primitive):
|
|||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
return [ gp.RegularPolygon(calc.x, calc.y, calc.diameter/2, 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 dilate(self, offset, unit):
|
||||
|
|
|
@ -10,10 +10,9 @@ from . import graphic_primitives as gp
|
|||
|
||||
def _flash_hole(self, x, y, unit=None, polarity_dark=True):
|
||||
if getattr(self, 'hole_rect_h', None) is not None:
|
||||
w, h = self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)
|
||||
return [*self._primitives(x, y, unit, polarity_dark),
|
||||
gp.Rectangle((x, y),
|
||||
(self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)),
|
||||
rotation=self.rotation, polarity_dark=(not polarity_dark))]
|
||||
gp.Rectangle(x, y, w, h, rotation=self.rotation, polarity_dark=(not polarity_dark))]
|
||||
elif self.hole_dia is not None:
|
||||
return [*self._primitives(x, y, unit, polarity_dark),
|
||||
gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))]
|
||||
|
@ -312,7 +311,7 @@ class ObroundAperture(Aperture):
|
|||
rotation : float = 0
|
||||
|
||||
def _primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
|
||||
return [ gp.Line.from_obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
|
||||
rotation=self.rotation, polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
|
@ -370,7 +369,7 @@ class PolygonAperture(Aperture):
|
|||
self.n_vertices = int(self.n_vertices)
|
||||
|
||||
def _primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.RegularPolygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices,
|
||||
return [ gp.ArcPoly.from_regular_polygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices,
|
||||
rotation=self.rotation, polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
@ -198,13 +198,55 @@ class FileSettings:
|
|||
return format(value, f'0{integer_digits+decimal_digits+1}.{decimal_digits}f')
|
||||
|
||||
|
||||
class Polyline:
|
||||
""" Class that is internally used to generate compact SVG renderings. Collectes a number of subsequent
|
||||
:py:class:`~.graphic_objects.Line` and :py:class:`~.graphic_objects.Arc` instances into one SVG <path>. """
|
||||
|
||||
def __init__(self, *lines):
|
||||
self.coords = []
|
||||
self.polarity_dark = None
|
||||
self.width = None
|
||||
|
||||
for line in lines:
|
||||
self.append(line)
|
||||
|
||||
def append(self, line):
|
||||
assert isinstance(line, Line)
|
||||
if not self.coords:
|
||||
self.coords.append((line.x1, line.y1))
|
||||
self.coords.append((line.x2, line.y2))
|
||||
self.polarity_dark = line.polarity_dark
|
||||
self.width = line.width
|
||||
return True
|
||||
|
||||
else:
|
||||
x, y = self.coords[-1]
|
||||
if self.polarity_dark == line.polarity_dark and self.width == line.width \
|
||||
and math.isclose(line.x1, x) and math.isclose(line.y1, y):
|
||||
self.coords.append((line.x2, line.y2))
|
||||
return True
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
if not self.coords:
|
||||
return None
|
||||
|
||||
(x0, y0), *rest = self.coords
|
||||
d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest)
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linejoin: round; stroke-linecap: round')
|
||||
|
||||
|
||||
class CamFile:
|
||||
def __init__(self, original_path=None, layer_name=None, import_settings=None):
|
||||
self.original_path = original_path
|
||||
self.layer_name = layer_name
|
||||
self.import_settings = import_settings
|
||||
|
||||
def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white'):
|
||||
def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white', tag=Tag):
|
||||
|
||||
if force_bounds is None:
|
||||
(min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
|
||||
|
@ -252,15 +294,15 @@ class CamFile:
|
|||
polyline = gp.Polyline(primitive)
|
||||
else:
|
||||
if not polyline.append(primitive):
|
||||
tags.append(polyline.to_svg(tag, fg, bg))
|
||||
tags.append(polyline.to_svg(fg, bg, tag=tag))
|
||||
polyline = gp.Polyline(primitive)
|
||||
else:
|
||||
if polyline:
|
||||
tags.append(polyline.to_svg(tag, fg, bg))
|
||||
tags.append(polyline.to_svg(fg, bg, tag=tag))
|
||||
polyline = None
|
||||
tags.append(primitive.to_svg(tag, fg, bg))
|
||||
tags.append(primitive.to_svg(fg, bg, tag=tag))
|
||||
if polyline:
|
||||
tags.append(polyline.to_svg(tag, fg, bg))
|
||||
tags.append(polyline.to_svg(fg, bg, tag=tag))
|
||||
|
||||
# setup viewport transform flipping y axis
|
||||
xform = f'translate({content_min_x} {content_min_y+content_h}) scale(1 -1) translate({-content_min_x} {-content_min_y})'
|
||||
|
|
|
@ -262,7 +262,8 @@ class Region(GraphicObject):
|
|||
|
||||
def append(self, obj):
|
||||
if obj.unit != self.unit:
|
||||
raise ValueError('Cannot append Polyline with "{obj.unit}" coords to Region with "{self.unit}" coords.')
|
||||
obj = obj.converted(self.unit)
|
||||
|
||||
if not self.poly.outline:
|
||||
self.poly.outline.append(obj.p1)
|
||||
self.poly.outline.append(obj.p2)
|
||||
|
|
|
@ -4,210 +4,70 @@ import itertools
|
|||
|
||||
from dataclasses import dataclass, KW_ONLY, replace
|
||||
|
||||
from .utils import *
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphicPrimitive:
|
||||
_ : KW_ONLY
|
||||
polarity_dark : bool = True
|
||||
|
||||
def bounding_box(self):
|
||||
""" Return the axis-aligned bounding box of this feature.
|
||||
|
||||
def rotate_point(x, y, angle, cx=0, cy=0):
|
||||
""" rotate point (x,y) around (cx,cy) clockwise angle radians """
|
||||
:returns: ``((min_x, min_Y), (max_x, max_y))``
|
||||
:rtype: tuple
|
||||
"""
|
||||
|
||||
return (cx + (x - cx) * math.cos(-angle) - (y - cy) * math.sin(-angle),
|
||||
cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle))
|
||||
raise NotImplementedError()
|
||||
|
||||
def min_none(a, b):
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return min(a, b)
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
""" Render this primitive into its SVG representation.
|
||||
|
||||
def max_none(a, b):
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return max(a, b)
|
||||
:param str fg: Foreground color. Must be an SVG color name.
|
||||
:param str bg: Background color. Must be an SVG color name.
|
||||
:param function tag: Tag constructor to use.
|
||||
|
||||
def add_bounds(b1, b2):
|
||||
(min_x_1, min_y_1), (max_x_1, max_y_1) = b1
|
||||
(min_x_2, min_y_2), (max_x_2, max_y_2) = b2
|
||||
min_x, min_y = min_none(min_x_1, min_x_2), min_none(min_y_1, min_y_2)
|
||||
max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2)
|
||||
return ((min_x, min_y), (max_x, max_y))
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
def rad_to_deg(x):
|
||||
return x/math.pi * 180
|
||||
|
||||
@dataclass
|
||||
class Circle(GraphicPrimitive):
|
||||
#: Center X coordinate
|
||||
x : float
|
||||
#: Center y coordinate
|
||||
y : float
|
||||
#: Radius, not diameter like in :py:class:`.apertures.CircleAperture`
|
||||
r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses.
|
||||
|
||||
def bounding_box(self):
|
||||
return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('circle', cx=self.x, cy=self.y, r=self.r, style=f'fill: {color}')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Obround(GraphicPrimitive):
|
||||
x : float
|
||||
y : float
|
||||
w : float
|
||||
h : float
|
||||
rotation : float # radians!
|
||||
|
||||
def to_line(self):
|
||||
if self.w > self.h:
|
||||
w, a, b = self.h, self.w-self.h, 0
|
||||
else:
|
||||
w, a, b = self.w, 0, self.h-self.w
|
||||
return Line(
|
||||
*rotate_point(self.x-a/2, self.y-b/2, self.rotation, self.x, self.y),
|
||||
*rotate_point(self.x+a/2, self.y+b/2, self.rotation, self.x, self.y),
|
||||
w, polarity_dark=self.polarity_dark)
|
||||
|
||||
def bounding_box(self):
|
||||
return self.to_line().bounding_box()
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
return self.to_line().to_svg(tag, fg, bg)
|
||||
|
||||
|
||||
def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
||||
# This is one of these problems typical for computer geometry where out of nowhere a seemingly simple task just
|
||||
# happens to be anything but in practice.
|
||||
#
|
||||
# Online there are a number of algorithms to be found solving this problem. Often, they solve the more general
|
||||
# problem for elliptic arcs. We can keep things simple here since we only have circular arcs.
|
||||
#
|
||||
# This solution manages to handle circular arcs given in gerber format (with explicit center and endpoints, plus
|
||||
# sweep direction instead of a format with e.g. angles and radius) without any trigonometric functions (e.g. atan2).
|
||||
#
|
||||
# cx, cy are relative to p1.
|
||||
|
||||
# Center arc on cx, cy
|
||||
cx += x1
|
||||
cy += y1
|
||||
x1 -= cx
|
||||
x2 -= cx
|
||||
y1 -= cy
|
||||
y2 -= cy
|
||||
clockwise = bool(clockwise) # bool'ify for XOR/XNOR below
|
||||
|
||||
# Calculate radius
|
||||
r = math.sqrt(x1**2 + y1**2)
|
||||
|
||||
# Calculate in which half-planes (north/south, west/east) P1 and P2 lie.
|
||||
# Note that we assume the y axis points upwards, as in Gerber and maths.
|
||||
# SVG has its y axis pointing downwards.
|
||||
p1_west = x1 < 0
|
||||
p1_north = y1 > 0
|
||||
p2_west = x2 < 0
|
||||
p2_north = y2 > 0
|
||||
|
||||
# Calculate bounding box of P1 and P2
|
||||
min_x = min(x1, x2)
|
||||
min_y = min(y1, y2)
|
||||
max_x = max(x1, x2)
|
||||
max_y = max(y1, y2)
|
||||
|
||||
# North
|
||||
# ^
|
||||
# |
|
||||
# |(0,0)
|
||||
# West <-----X-----> East
|
||||
# |
|
||||
# +Y |
|
||||
# ^ v
|
||||
# | South
|
||||
# |
|
||||
# +-----> +X
|
||||
#
|
||||
# Check whether the arc sweeps over any coordinate axes. If it does, add the intersection point to the bounding box.
|
||||
# Note that, since this intersection point is at radius r, it has coordinate e.g. (0, r) for the north intersection.
|
||||
# Since we know that the points lie on either side of the coordinate axis, the '0' coordinate of the intersection
|
||||
# point will not change the bounding box in that axis--only its 'r' coordinate matters. We also know that the
|
||||
# absolute value of that coordinate will be greater than or equal to the old coordinate in that direction since the
|
||||
# intersection with the axis is the point where the full circle is tangent to the AABB. Thus, we can blindly set the
|
||||
# corresponding coordinate of the bounding box without min()/max()'ing first.
|
||||
|
||||
# Handle north/south halfplanes
|
||||
if p1_west != p2_west: # arc starts in west half-plane, ends in east half-plane
|
||||
if p1_west == clockwise: # arc is clockwise west -> east or counter-clockwise east -> west
|
||||
max_y = r # add north to bounding box
|
||||
else: # arc is counter-clockwise west -> east or clockwise east -> west
|
||||
min_y = -r # south
|
||||
else: # Arc starts and ends in same halfplane west/east
|
||||
# Since both points are on the arc (at same radius) in one halfplane, we can use the y coord as a proxy for
|
||||
# angle comparisons.
|
||||
small_arc_is_north_to_south = y1 > y2
|
||||
small_arc_is_clockwise = small_arc_is_north_to_south == p1_west
|
||||
if small_arc_is_clockwise != clockwise:
|
||||
min_y, max_y = -r, r # intersect aabb with both north and south
|
||||
|
||||
# Handle west/east halfplanes
|
||||
if p1_north != p2_north:
|
||||
if p1_north == clockwise:
|
||||
max_x = r # east
|
||||
else:
|
||||
min_x = -r # west
|
||||
else:
|
||||
small_arc_is_west_to_east = x1 < x2
|
||||
small_arc_is_clockwise = small_arc_is_west_to_east == p1_north
|
||||
if small_arc_is_clockwise != clockwise:
|
||||
min_x, max_x = -r, r # intersect aabb with both north and south
|
||||
|
||||
return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy)
|
||||
|
||||
|
||||
def point_line_distance(l1, l2, p):
|
||||
# https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
|
||||
x1, y1 = l1
|
||||
x2, y2 = l2
|
||||
x0, y0 = p
|
||||
length = math.dist(l1, l2)
|
||||
if math.isclose(length, 0):
|
||||
return math.dist(l1, p)
|
||||
return ((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length
|
||||
|
||||
def svg_arc(old, new, center, clockwise):
|
||||
r = math.hypot(*center)
|
||||
# invert sweep flag since the svg y axis is mirrored
|
||||
sweep_flag = int(not clockwise)
|
||||
# In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc"
|
||||
# in SVG, we have to split it into two.
|
||||
if math.isclose(math.dist(old, new), 0):
|
||||
intermediate = old[0] + 2*center[0], old[1] + 2*center[1]
|
||||
# Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of
|
||||
# a circular cutin
|
||||
return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {intermediate[0]:.6} {intermediate[1]:.6} ' +\
|
||||
f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
|
||||
else: # normal case
|
||||
d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1]))
|
||||
large_arc = int((d < 0) == clockwise)
|
||||
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
|
||||
@dataclass
|
||||
class ArcPoly(GraphicPrimitive):
|
||||
""" Polygon whose sides may be either straight lines or circular arcs """
|
||||
""" Polygon whose sides may be either straight lines or circular arcs. """
|
||||
|
||||
# list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered
|
||||
# connected.
|
||||
#: list of (x : float, y : float) tuples. Describes closed outline, i.e. the first and last point are considered
|
||||
#: connected.
|
||||
outline : list
|
||||
# must be either None (all segments are straight lines) or same length as outline.
|
||||
# Straight line segments have None entry.
|
||||
#: Must be either None (all segments are straight lines) or same length as outline.
|
||||
#: Straight line segments have None entry.
|
||||
arc_centers : list = None
|
||||
|
||||
@property
|
||||
def segments(self):
|
||||
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
|
||||
iterator will yield a ``(p1, p2, center)`` tuple. If the segment is a straight line, ``center`` will be
|
||||
``None``.
|
||||
"""
|
||||
ol = self.outline
|
||||
return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
|
||||
|
||||
|
@ -223,10 +83,24 @@ class ArcPoly(GraphicPrimitive):
|
|||
bbox = add_bounds(bbox, line_bounds)
|
||||
return bbox
|
||||
|
||||
@classmethod
|
||||
def from_regular_polygon(kls, x:float, y:float, r:float, n:int, rotation:float=0, polarity_dark:bool=True):
|
||||
""" Convert an n-sided gerber polygon to a normal ArcPoly defined by outline """
|
||||
|
||||
delta = 2*math.pi / self.n
|
||||
|
||||
return kls([
|
||||
(self.x + math.cos(self.rotation + i*delta) * self.r,
|
||||
self.y + math.sin(self.rotation + i*delta) * self.r)
|
||||
for i in range(self.n) ], polarity_dark=polarity_dark)
|
||||
|
||||
def __len__(self):
|
||||
""" Return the number of points on this polygon's outline (which is also the number of segments because the
|
||||
polygon is closed). """
|
||||
return len(self.outline)
|
||||
|
||||
def __bool__(self):
|
||||
""" Return ``True`` if this polygon has any outline points. """
|
||||
return bool(len(self))
|
||||
|
||||
def _path_d(self):
|
||||
|
@ -242,61 +116,44 @@ class ArcPoly(GraphicPrimitive):
|
|||
clockwise, center = arc
|
||||
yield svg_arc(old, new, center, clockwise)
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
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}')
|
||||
|
||||
class Polyline:
|
||||
def __init__(self, *lines):
|
||||
self.coords = []
|
||||
self.polarity_dark = None
|
||||
self.width = None
|
||||
|
||||
for line in lines:
|
||||
self.append(line)
|
||||
|
||||
def append(self, line):
|
||||
assert isinstance(line, Line)
|
||||
if not self.coords:
|
||||
self.coords.append((line.x1, line.y1))
|
||||
self.coords.append((line.x2, line.y2))
|
||||
self.polarity_dark = line.polarity_dark
|
||||
self.width = line.width
|
||||
return True
|
||||
|
||||
else:
|
||||
x, y = self.coords[-1]
|
||||
if self.polarity_dark == line.polarity_dark and self.width == line.width \
|
||||
and math.isclose(line.x1, x) and math.isclose(line.y1, y):
|
||||
self.coords.append((line.x2, line.y2))
|
||||
return True
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
color = fg if self.polarity_dark else bg
|
||||
if not self.coords:
|
||||
return None
|
||||
|
||||
(x0, y0), *rest = self.coords
|
||||
d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest)
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linejoin: round; stroke-linecap: round')
|
||||
|
||||
@dataclass
|
||||
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
|
||||
#: line.
|
||||
x1 : float
|
||||
#: Start Y coordinate
|
||||
y1 : float
|
||||
#: End X coordinate
|
||||
x2 : float
|
||||
#: End Y coordinate
|
||||
y2 : float
|
||||
#: Line width
|
||||
width : float
|
||||
|
||||
@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`. """
|
||||
if self.w > self.h:
|
||||
w, a, b = self.h, self.w-self.h, 0
|
||||
else:
|
||||
w, a, b = self.w, 0, self.h-self.w
|
||||
|
||||
return kls(
|
||||
*rotate_point(self.x-a/2, self.y-b/2, self.rotation, self.x, self.y),
|
||||
*rotate_point(self.x+a/2, self.y+b/2, self.rotation, self.x, self.y),
|
||||
w, polarity_dark=self.polarity_dark)
|
||||
|
||||
def bounding_box(self):
|
||||
r = self.width / 2
|
||||
return add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
|
||||
|
@ -304,14 +161,23 @@ class Line(GraphicPrimitive):
|
|||
|
||||
@dataclass
|
||||
class Arc(GraphicPrimitive):
|
||||
""" Circular arc with line width ``width`` going from ``(x1, y1)`` to ``(x2, y2)`` around center at ``(cx, cy)``. """
|
||||
#: Start X coodinate
|
||||
x1 : float
|
||||
#: Start Y coodinate
|
||||
y1 : float
|
||||
#: End X coodinate
|
||||
x2 : float
|
||||
#: End Y coodinate
|
||||
y2 : float
|
||||
# absolute coordinates
|
||||
#: Center X coordinate relative to ``x1``
|
||||
cx : float
|
||||
#: Center Y coordinate relative to ``y1``
|
||||
cy : float
|
||||
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
|
||||
#: start, end and center
|
||||
clockwise : bool
|
||||
#: Line width of this arc.
|
||||
width : float
|
||||
|
||||
def bounding_box(self):
|
||||
|
@ -333,24 +199,25 @@ class Arc(GraphicPrimitive):
|
|||
arc = arc_bounds(x1, y1, x2, y2, self.cx, self.cy, self.clockwise)
|
||||
return add_bounds(endpoints, arc) # FIXME add "include_center" switch
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
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')
|
||||
|
||||
def svg_rotation(angle_rad, cx=0, cy=0):
|
||||
return f'rotate({float(rad_to_deg(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'
|
||||
|
||||
@dataclass
|
||||
class Rectangle(GraphicPrimitive):
|
||||
# coordinates are center coordinates
|
||||
#: **Center** X coordinate
|
||||
x : float
|
||||
#: **Center** Y coordinate
|
||||
y : float
|
||||
#: width
|
||||
w : float
|
||||
#: height
|
||||
h : float
|
||||
rotation : float # radians, around center!
|
||||
#: rotation around center in radians
|
||||
rotation : float
|
||||
|
||||
def bounding_box(self):
|
||||
return self.to_arc_poly().bounding_box()
|
||||
|
@ -367,37 +234,9 @@ class Rectangle(GraphicPrimitive):
|
|||
(x + (cw+sh), y - (ch+sw)),
|
||||
])
|
||||
|
||||
@property
|
||||
def center(self):
|
||||
return self.x + self.w/2, self.y + self.h/2
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
x, y = self.x - self.w/2, self.y - self.h/2
|
||||
return tag('rect', x=x, y=y, width=self.w, height=self.h,
|
||||
transform=svg_rotation(self.rotation, self.x, self.y), style=f'fill: {color}')
|
||||
|
||||
@dataclass
|
||||
class RegularPolygon(GraphicPrimitive):
|
||||
x : float
|
||||
y : float
|
||||
r : float
|
||||
n : int
|
||||
rotation : float # radians!
|
||||
|
||||
def to_arc_poly(self):
|
||||
''' convert n-sided gerber polygon to normal ArcPoly defined by outline '''
|
||||
|
||||
delta = 2*math.pi / self.n
|
||||
|
||||
return ArcPoly([
|
||||
(self.x + math.cos(self.rotation + i*delta) * self.r,
|
||||
self.y + math.sin(self.rotation + i*delta) * self.r)
|
||||
for i in range(self.n) ])
|
||||
|
||||
def bounding_box(self):
|
||||
return self.to_arc_poly().bounding_box()
|
||||
|
||||
def to_svg(self, tag, fg, bg):
|
||||
return self.to_arc_poly().to_svg(tag, fg, bg)
|
||||
|
||||
|
|
|
@ -24,12 +24,10 @@ import re
|
|||
import math
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from itertools import count, chain
|
||||
from io import StringIO
|
||||
import dataclasses
|
||||
|
||||
from .cam import CamFile, FileSettings
|
||||
from .utils import sq_distance, rotate_point, MM, Inch, units, InterpMode, UnknownStatementWarning
|
||||
from .utils import MM, Inch, units, InterpMode, UnknownStatementWarning
|
||||
from .aperture_macros.parse import ApertureMacro, GenericMacros
|
||||
from . import graphic_primitives as gp
|
||||
from . import graphic_objects as go
|
||||
|
|
|
@ -30,9 +30,11 @@ from enum import Enum
|
|||
from math import radians, sin, cos, sqrt, atan2, pi
|
||||
|
||||
class UnknownStatementWarning(Warning):
|
||||
""" Gerbonara found an unknown Gerber or Excellon statement. """
|
||||
pass
|
||||
|
||||
class RegexMatcher:
|
||||
""" Internal parsing helper """
|
||||
def __init__(self):
|
||||
self.mapping = {}
|
||||
|
||||
|
@ -51,13 +53,27 @@ class RegexMatcher:
|
|||
else:
|
||||
return False
|
||||
|
||||
|
||||
class LengthUnit:
|
||||
""" Convenience length unit class. Used in :py:class:`.GraphicObject` and :py:class:`.Aperture` to store lenght
|
||||
information. Provides a number of useful unit conversion functions.
|
||||
|
||||
Singleton, use only global instances ``utils.MM`` and ``utils.Inch``.
|
||||
"""
|
||||
|
||||
def __init__(self, name, shorthand, this_in_mm):
|
||||
self.name = name
|
||||
self.shorthand = shorthand
|
||||
self.factor = this_in_mm
|
||||
|
||||
def convert_from(self, unit, value):
|
||||
""" Convert ``value`` from ``unit`` into this unit.
|
||||
|
||||
:param unit: ``MM``, ``Inch`` or one of the strings ``"mm"`` or ``"inch"``
|
||||
:param float value:
|
||||
:rtype: float
|
||||
"""
|
||||
|
||||
if isinstance(unit, str):
|
||||
unit = units[unit]
|
||||
|
||||
|
@ -67,6 +83,8 @@ class LengthUnit:
|
|||
return value * unit.factor / self.factor
|
||||
|
||||
def convert_to(self, unit, value):
|
||||
""" :py:meth:`.LengthUnit.convert_from` but in reverse. """
|
||||
|
||||
if isinstance(unit, str):
|
||||
unit = to_unit(unit)
|
||||
|
||||
|
@ -76,9 +94,17 @@ class LengthUnit:
|
|||
return unit.convert_from(self, value)
|
||||
|
||||
def format(self, value):
|
||||
""" Return a human-readdable string representing value in this unit.
|
||||
|
||||
:param float value:
|
||||
:returns: something like "3mm"
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
return f'{value:.3f}{self.shorthand}' if value is not None else ''
|
||||
|
||||
def __call__(self, value, unit):
|
||||
""" Convenience alias for :py:meth:`.LengthUnit.convert_from` """
|
||||
return self.convert_from(unit, value)
|
||||
|
||||
def __eq__(self, other):
|
||||
|
@ -105,12 +131,41 @@ MILLIMETERS_PER_INCH = 25.4
|
|||
Inch = LengthUnit('inch', 'in', MILLIMETERS_PER_INCH)
|
||||
MM = LengthUnit('millimeter', 'mm', 1)
|
||||
units = {'inch': Inch, 'mm': MM, None: None}
|
||||
to_unit = lambda name: units[name.lower() if name else None]
|
||||
|
||||
def _raise_error(*args, **kwargs):
|
||||
raise SystemError('LengthUnit is a singleton. Use gerbonara.utils.MM or gerbonara.utils.Inch. Please do not invent '
|
||||
'your own length units, the imperial system is already messed up enough.')
|
||||
LengthUnit.__init__ = _raise_error
|
||||
|
||||
def to_unit(name):
|
||||
""" Convert string ``name`` into a registered length unit. Returns ``None`` if the argument cannot be converted.
|
||||
|
||||
:param str name: ``'mm'`` or ``'inch'``
|
||||
:returns: ``MM``, ``Inch`` or ``None``
|
||||
:rtype: :py:class:`.LengthUnit` or ``None``
|
||||
"""
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
if isinstance(name, LengthUnit):
|
||||
return name
|
||||
|
||||
if isinstance(name, str):
|
||||
name = name.lower()
|
||||
if name in units:
|
||||
return units[name]
|
||||
|
||||
raise ValueError(f'Invalid unit {name!r}. Should be either "mm", "inch" or None for no unit.')
|
||||
|
||||
|
||||
class InterpMode(Enum):
|
||||
""" Gerber / Excellon interpolation mode. """
|
||||
#: straight line
|
||||
LINEAR = 0
|
||||
#: clockwise circular arc
|
||||
CIRCULAR_CW = 1
|
||||
#: counterclockwise circular arc
|
||||
CIRCULAR_CCW = 2
|
||||
|
||||
|
||||
|
@ -151,56 +206,53 @@ def decimal_string(value, precision=6, padding=False):
|
|||
else:
|
||||
return int(floatstr)
|
||||
|
||||
def validate_coordinates(position):
|
||||
if position is not None:
|
||||
if len(position) != 2:
|
||||
raise TypeError('Position must be a tuple (n=2) of coordinates')
|
||||
else:
|
||||
for coord in position:
|
||||
if not (isinstance(coord, int) or isinstance(coord, float)):
|
||||
raise TypeError('Coordinates must be integers or floats')
|
||||
|
||||
def rotate_point(point, angle, center=(0.0, 0.0)):
|
||||
""" Rotate a point about another point.
|
||||
def rotate_point(x, y, angle, cx=0, cy=0):
|
||||
""" Rotate point (x,y) around (cx,cy) by ``angle`` radians clockwise. """
|
||||
|
||||
Parameters
|
||||
-----------
|
||||
point : tuple(<float>, <float>)
|
||||
Point to rotate about origin or center point
|
||||
return (cx + (x - cx) * math.cos(-angle) - (y - cy) * math.sin(-angle),
|
||||
cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle))
|
||||
|
||||
angle : float
|
||||
Angle to rotate the point [degrees]
|
||||
|
||||
center : tuple(<float>, <float>)
|
||||
Coordinates about which the point is rotated. Defaults to the origin.
|
||||
def min_none(a, b):
|
||||
""" Like the ``min(..)`` builtin, but if either value is ``None``, returns the other. """
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return min(a, b)
|
||||
|
||||
Returns
|
||||
-------
|
||||
rotated_point : tuple(<float>, <float>)
|
||||
`point` rotated about `center` by `angle` degrees.
|
||||
|
||||
def max_none(a, b):
|
||||
""" Like the ``max(..)`` builtin, but if either value is ``None``, returns the other. """
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return max(a, b)
|
||||
|
||||
|
||||
def add_bounds(b1, b2):
|
||||
""" Add/union two bounding boxes.
|
||||
|
||||
:param tuple b1: ``((min_x, min_y), (max_x, max_y))``
|
||||
:param tuple b2: ``((min_x, min_y), (max_x, max_y))``
|
||||
|
||||
:returns: ``((min_x, min_y), (max_x, max_y))``
|
||||
:rtype: tuple
|
||||
"""
|
||||
angle = radians(angle)
|
||||
|
||||
cos_angle = cos(angle)
|
||||
sin_angle = sin(angle)
|
||||
(min_x_1, min_y_1), (max_x_1, max_y_1) = b1
|
||||
(min_x_2, min_y_2), (max_x_2, max_y_2) = b2
|
||||
min_x, min_y = min_none(min_x_1, min_x_2), min_none(min_y_1, min_y_2)
|
||||
max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2)
|
||||
return ((min_x, min_y), (max_x, max_y))
|
||||
|
||||
return (
|
||||
cos_angle * (point[0] - center[0]) - sin_angle * (point[1] - center[1]) + center[0],
|
||||
sin_angle * (point[0] - center[0]) + cos_angle * (point[1] - center[1]) + center[1])
|
||||
|
||||
def nearly_equal(point1, point2, ndigits = 6):
|
||||
'''Are the points nearly equal'''
|
||||
|
||||
return round(point1[0] - point2[0], ndigits) == 0 and round(point1[1] - point2[1], ndigits) == 0
|
||||
|
||||
|
||||
def sq_distance(point1, point2):
|
||||
|
||||
diff1 = point1[0] - point2[0]
|
||||
diff2 = point1[1] - point2[1]
|
||||
return diff1 * diff1 + diff2 * diff2
|
||||
|
||||
class Tag:
|
||||
""" Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your
|
||||
own implementation by passing a ``tag`` parameter. """
|
||||
|
||||
def __init__(self, name, children=None, root=False, **attrs):
|
||||
self.name, self.attrs = name, attrs
|
||||
self.children = children or []
|
||||
|
@ -216,3 +268,133 @@ class Tag:
|
|||
return f'{prefix}<{opening}/>'
|
||||
|
||||
|
||||
def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
||||
""" Calculate bounding box of a circular arc given in Gerber notation (i.e. with center relative to first point).
|
||||
|
||||
:returns: ``((x_min, y_min), (x_max, y_max))``
|
||||
"""
|
||||
# This is one of these problems typical for computer geometry where out of nowhere a seemingly simple task just
|
||||
# happens to be anything but in practice.
|
||||
#
|
||||
# Online there are a number of algorithms to be found solving this problem. Often, they solve the more general
|
||||
# problem for elliptic arcs. We can keep things simple here since we only have circular arcs.
|
||||
#
|
||||
# This solution manages to handle circular arcs given in gerber format (with explicit center and endpoints, plus
|
||||
# sweep direction instead of a format with e.g. angles and radius) without any trigonometric functions (e.g. atan2).
|
||||
#
|
||||
# cx, cy are relative to p1.
|
||||
|
||||
# Center arc on cx, cy
|
||||
cx += x1
|
||||
cy += y1
|
||||
x1 -= cx
|
||||
x2 -= cx
|
||||
y1 -= cy
|
||||
y2 -= cy
|
||||
clockwise = bool(clockwise) # bool'ify for XOR/XNOR below
|
||||
|
||||
# Calculate radius
|
||||
r = math.sqrt(x1**2 + y1**2)
|
||||
|
||||
# Calculate in which half-planes (north/south, west/east) P1 and P2 lie.
|
||||
# Note that we assume the y axis points upwards, as in Gerber and maths.
|
||||
# SVG has its y axis pointing downwards.
|
||||
p1_west = x1 < 0
|
||||
p1_north = y1 > 0
|
||||
p2_west = x2 < 0
|
||||
p2_north = y2 > 0
|
||||
|
||||
# Calculate bounding box of P1 and P2
|
||||
min_x = min(x1, x2)
|
||||
min_y = min(y1, y2)
|
||||
max_x = max(x1, x2)
|
||||
max_y = max(y1, y2)
|
||||
|
||||
# North
|
||||
# ^
|
||||
# |
|
||||
# |(0,0)
|
||||
# West <-----X-----> East
|
||||
# |
|
||||
# +Y |
|
||||
# ^ v
|
||||
# | South
|
||||
# |
|
||||
# +-----> +X
|
||||
#
|
||||
# Check whether the arc sweeps over any coordinate axes. If it does, add the intersection point to the bounding box.
|
||||
# Note that, since this intersection point is at radius r, it has coordinate e.g. (0, r) for the north intersection.
|
||||
# Since we know that the points lie on either side of the coordinate axis, the '0' coordinate of the intersection
|
||||
# point will not change the bounding box in that axis--only its 'r' coordinate matters. We also know that the
|
||||
# absolute value of that coordinate will be greater than or equal to the old coordinate in that direction since the
|
||||
# intersection with the axis is the point where the full circle is tangent to the AABB. Thus, we can blindly set the
|
||||
# corresponding coordinate of the bounding box without min()/max()'ing first.
|
||||
|
||||
# Handle north/south halfplanes
|
||||
if p1_west != p2_west: # arc starts in west half-plane, ends in east half-plane
|
||||
if p1_west == clockwise: # arc is clockwise west -> east or counter-clockwise east -> west
|
||||
max_y = r # add north to bounding box
|
||||
else: # arc is counter-clockwise west -> east or clockwise east -> west
|
||||
min_y = -r # south
|
||||
else: # Arc starts and ends in same halfplane west/east
|
||||
# Since both points are on the arc (at same radius) in one halfplane, we can use the y coord as a proxy for
|
||||
# angle comparisons.
|
||||
small_arc_is_north_to_south = y1 > y2
|
||||
small_arc_is_clockwise = small_arc_is_north_to_south == p1_west
|
||||
if small_arc_is_clockwise != clockwise:
|
||||
min_y, max_y = -r, r # intersect aabb with both north and south
|
||||
|
||||
# Handle west/east halfplanes
|
||||
if p1_north != p2_north:
|
||||
if p1_north == clockwise:
|
||||
max_x = r # east
|
||||
else:
|
||||
min_x = -r # west
|
||||
else:
|
||||
small_arc_is_west_to_east = x1 < x2
|
||||
small_arc_is_clockwise = small_arc_is_west_to_east == p1_north
|
||||
if small_arc_is_clockwise != clockwise:
|
||||
min_x, max_x = -r, r # intersect aabb with both north and south
|
||||
|
||||
return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy)
|
||||
|
||||
|
||||
def point_line_distance(l1, l2, p):
|
||||
""" Calculate distance between infinite line through l1 and l2, and point p. """
|
||||
# https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
|
||||
x1, y1 = l1
|
||||
x2, y2 = l2
|
||||
x0, y0 = p
|
||||
length = math.dist(l1, l2)
|
||||
if math.isclose(length, 0):
|
||||
return math.dist(l1, p)
|
||||
return ((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length
|
||||
|
||||
|
||||
def svg_arc(old, new, center, clockwise):
|
||||
""" Format an SVG circular arc "A" path data entry given an arc in Gerber notation (i.e. with center relative to
|
||||
first point).
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
r = math.hypot(*center)
|
||||
# invert sweep flag since the svg y axis is mirrored
|
||||
sweep_flag = int(not clockwise)
|
||||
# In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc"
|
||||
# in SVG, we have to split it into two.
|
||||
if math.isclose(math.dist(old, new), 0):
|
||||
intermediate = old[0] + 2*center[0], old[1] + 2*center[1]
|
||||
# Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of
|
||||
# a circular cutin
|
||||
return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {intermediate[0]:.6} {intermediate[1]:.6} ' +\
|
||||
f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
|
||||
else: # normal case
|
||||
d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1]))
|
||||
large_arc = int((d < 0) == clockwise)
|
||||
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
|
||||
|
||||
def svg_rotation(angle_rad, cx=0, cy=0):
|
||||
return f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue