This commit is contained in:
jaseg 2023-09-22 18:50:31 +02:00
parent d2143bdf4d
commit 95da482033
6 changed files with 212 additions and 118 deletions

View file

@ -48,6 +48,7 @@ Features
cli
api-concepts
examples
file-api
object-api
apertures
@ -74,6 +75,8 @@ Then, you are ready to read and write gerber files:
w, h = stack.outline.size('mm')
print(f'Board size is {w:.1f} mm x {h:.1f} mm')
You can find some more elaborate examples in this doc's :ref:`Examples section<examples-doc>`.
Command-Line Interface
======================

View file

@ -285,7 +285,7 @@ class Arc:
x2, y2 = self.mid.x-x1, self.mid.y-x2
x3, y3 = (self.end.x - x1)/2, (self.end.y - y1)/2
clockwise = math.atan2(x2*y3-x3*y2, x2*x3+y2*y3) > 0
return arc_bounds(x1, y1, self.end.x, self.end.y, cx-x1, cy-y1, clockwise)
return arc_bounds(x1, y1, self.end.x, self.end.y, cx, cy, clockwise)
def to_svg(self, colorscheme=Colorscheme.KiCad):

View file

@ -19,9 +19,9 @@
import math
import copy
from dataclasses import dataclass, astuple, field, fields
from itertools import zip_longest
from itertools import zip_longest, pairwise, islice, cycle
from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds
from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds, approximate_arc, sweep_angle
from . import graphic_primitives as gp
from .aperture_macros import primitive as amp
@ -278,9 +278,15 @@ class Region(GraphicObject):
* A region is always exactly one connected component.
* A region must not overlap itself anywhere.
* A region cannot have holes.
* The last outline point of the region must be equal to the first.
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.
When ``arc_centers`` is empty, this region has only straight outline segments. When ``arc_centers`` is not empty,
the i-th entry defines the i-th outline segment, with a ``None`` entry designating a straight line segment.
An arc is defined by a ``(clockwise, (cx, cy))`` tuple, where ``clockwise`` can be ``True`` for a clockwise arc, or
``False`` for a counter-clockwise arc. ``cx`` and ``cy`` are the absolute coordinates of the arc's center.
"""
def __init__(self, outline=None, arc_centers=None, *, unit=MM, polarity_dark=True):
@ -304,8 +310,8 @@ class Region(GraphicObject):
def _rotate(self, angle, cx=0, cy=0):
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_longest(self.outline, self.arc_centers) ]
(arc[0], gp.rotate_point(*arc[1], angle, cx, cy)) if arc else None
for arc in self.arc_centers ]
def _scale(self, factor):
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
@ -322,6 +328,10 @@ class Region(GraphicObject):
(x, y+h),
], unit=unit)
@classmethod
def from_arc_poly(kls, arc_poly, polarity_dark=True, unit=MM):
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity_dark, unit=unit)
def append(self, obj):
if obj.unit != self.unit:
obj = obj.converted(self.unit)
@ -331,48 +341,49 @@ class Region(GraphicObject):
self.outline.append(obj.p2)
if isinstance(obj, Arc):
self.arc_centers.append((obj.clockwise, obj.center_relative))
self.arc_centers.append((obj.clockwise, obj.center))
else:
self.arc_centers.append(None)
def close(self):
if not self.outline:
return
def iter_segments(self, tolerance=1e-6):
for points, arc in zip_longest(pairwise(self.outline), self.arc_centers):
if arc:
if points:
yield *points, arc
else:
yield self.outline[-1], self.outline[0], arc
return
else:
if not points:
break
yield *points, (None, (None, None))
if self.outline[-1] != self.outline[0]:
self.outline.append(self.outline[0])
# Close outline if necessary.
if math.dist(self.outline[0], self.outline[-1]) > tolerance:
yield self.outline[-1], self.outline[0], (None, (None, None))
def outline_objects(self, aperture=None):
for p1, p2, arc in zip_longest(self.outline, self.outline[1:] + self.outline[:1], self.arc_centers):
if arc:
clockwise, pc = arc
yield Arc(*p1, *p2, *pc, clockwise, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
for p1, p2, (clockwise, center) in self.iter_segments():
if center:
yield Arc(*p1, *p2, *center, clockwise, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
else:
yield Line(*p1, *p2, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
def _aperture_macro_primitives(self, max_error=1e-2, unit=MM):
def _aperture_macro_primitives(self, max_error=1e-2, clip_max_error=True, unit=MM):
# unit is only for max_error, the resulting primitives will always be in MM
if len(self.outline) < 2:
return
points = [self.outline[0]]
for p1, p2, arc in zip_longest(self.outline[:-1], self.outline[1:], self.arc_centers):
if arc:
clockwise, pc = arc
#r = math.hypot(*pc) # arc center is relative to p1.
#d = math.dist(p1, p2)
#err = r - math.sqrt(r**2 - (d/(2*n))**2)
#n = math.ceil(1/(2*math.sqrt(r**2 - (r - max_err)**2)/d))
arc = Arc(*p1, *p2, *pc, clockwise, unit=self.unit, polarity_dark=self.polarity_dark, aperture=None)
for line in arc.approximate(max_error=max_error, unit=unit):
points.append(line.p2)
points = []
for p1, p2, (clockwise, center) in self.iter_segments():
if center:
for p in approximate_arc(*center, *p1, *p2, clockwise,
max_error=max_error, clip_max_error=clip_max_error):
points.append(p)
points.pop()
else:
points.append(p2)
if points[-1] != points[0]:
points.append(points[0])
points.append(p1)
yield amp.Outline(self.unit, int(self.polarity_dark), len(points)-1, tuple(coord for p in points for coord in p))
@ -389,6 +400,9 @@ class Region(GraphicObject):
yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark)
def to_statements(self, gs):
if len(self.outline) < 3:
return
yield from gs.set_polarity(self.polarity_dark)
yield 'G36*'
# Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will
@ -398,32 +412,24 @@ class Region(GraphicObject):
yield from gs.set_current_point(self.outline[0], unit=self.unit)
for point, arc_center in zip_longest(self.outline[1:], self.arc_centers):
if point is None and arc_center is None:
for previous_point, point, (clockwise, center) in self.iter_segments():
if point is None and center is None:
break
if arc_center is None:
yield from gs.set_interpolation_mode(InterpMode.LINEAR)
x = gs.file_settings.write_gerber_value(point[0], self.unit)
y = gs.file_settings.write_gerber_value(point[1], self.unit)
x = gs.file_settings.write_gerber_value(point[0], self.unit)
y = gs.file_settings.write_gerber_value(point[1], self.unit)
if clockwise is None:
yield from gs.set_interpolation_mode(InterpMode.LINEAR)
yield f'X{x}Y{y}D01*'
gs.update_point(*point, unit=self.unit)
else:
clockwise, (cx, cy) = arc_center
x2, y2 = point
yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW)
x = gs.file_settings.write_gerber_value(x2, self.unit)
y = gs.file_settings.write_gerber_value(y2, self.unit)
# TODO are these coordinates absolute or relative now?!
i = gs.file_settings.write_gerber_value(cx, self.unit)
j = gs.file_settings.write_gerber_value(cy, self.unit)
i = gs.file_settings.write_gerber_value(center[0]-previous_point[0], self.unit)
j = gs.file_settings.write_gerber_value(center[1]-previous_point[1], self.unit)
yield f'X{x}Y{y}I{i}J{j}D01*'
gs.update_point(x2, y2, unit=self.unit)
gs.update_point(*point, unit=self.unit)
yield 'G37*'
@ -605,22 +611,8 @@ class Arc(GraphicObject):
:returns: Angle in clockwise radian between ``0`` and ``2*math.pi``
:rtype: float
"""
cx, cy = self.cx + self.x1, self.cy + self.y1
x1, y1 = self.x1 - cx, self.y1 - cy
x2, y2 = self.x2 - cx, self.y2 - cy
a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2)
f = abs(a2 - a1)
if not self.clockwise:
if a2 > a1:
return a2 - a1
else:
return 2*math.pi - abs(a2 - a1)
else:
if a1 > a2:
return a1 - a2
else:
return 2*math.pi - abs(a1 - a2)
return sweep_angle(self.cx+self.x1, self.cy+self.y1, self.x1, self.y1, self.x2, self.y2, self.clockwise)
@property
def p1(self):
@ -677,34 +669,16 @@ class Arc(GraphicObject):
:returns: list of :py:class:`~.graphic_objects.Line` instances.
:rtype: list
"""
# TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer
# results than necessary. Fix this.
r = math.hypot(self.cx, self.cy)
max_error = self.unit(max_error, unit)
if clip_max_error:
# 1 - math.sqrt(1 - 0.5*math.sqrt(2))
max_error = min(max_error, r*0.4588038998538031)
elif max_error >= r:
return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)]
# see https://www.mathopenref.com/sagitta.html
l = math.sqrt(r**2 - (r - max_error)**2)
angle_max = math.asin(l/r)
sweep_angle = self.sweep_angle()
num_segments = math.ceil(sweep_angle / angle_max)
angle = sweep_angle / num_segments
if not self.clockwise:
angle = -angle
cx, cy = self.center
points = [ rotate_point(self.x1, self.y1, i*angle, cx, cy) for i in range(num_segments + 1) ]
return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)
for p1, p2 in zip(points[0::], points[1::]) ]
return [Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)
for p1, p2 in pairwise(approximate_arc(
self.cx+self.x1, self.cy+self.y1,
self.x1, self.y1,
self.x2, self.y2,
self.clockwise,
max_error=max_error,
clip_max_error=clip_max_error))]
def _rotate(self, rotation, cx=0, cy=0):
# rotate center first since we need old x1, y1 here
@ -726,7 +700,7 @@ class Arc(GraphicObject):
w = self.aperture.equivalent_width(unit) if self.aperture else 0
return gp.Arc(x1=conv.x1, y1=conv.y1,
x2=conv.x2, y2=conv.y2,
cx=conv.cx, cy=conv.cy,
cx=conv.cx+conv.x1, cy=conv.cy+conv.y1,
clockwise=self.clockwise,
width=w,
polarity_dark=self.polarity_dark)

View file

@ -19,7 +19,7 @@
import math
import itertools
from dataclasses import dataclass, replace
from dataclasses import dataclass, replace, field
from .utils import *
@ -79,6 +79,10 @@ class Circle(GraphicPrimitive):
color = fg if self.polarity_dark else bg
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color)
def to_arc_poly(self):
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
[(True, (self.x, self.y)), (True, (self.x, self.y))])
@dataclass(frozen=True)
class ArcPoly(GraphicPrimitive):
@ -88,28 +92,51 @@ class ArcPoly(GraphicPrimitive):
#: connected.
outline : list
#: Must be either None (all segments are straight lines) or same length as outline.
#: Straight line segments have None entry.
arc_centers : list = None
#: Straight line segments have None entry. Arc segments have (clockwise, (cx, cy)) tuple with cx, cy being absolute
#: coords.
arc_centers : list = field(default_factory=list)
@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``.
iterator will yield a ``(p1, p2, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise``
will be ``None``.
"""
ol = self.outline
return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
for points, arc in itertools.zip_longest(itertools.pairwise(self.outline), self.arc_centers):
if arc:
if points:
yield *points, arc
else:
yield self.outline[-1], self.outline[0], arc
return
else:
if not points:
break
yield *points, (None, (None, None))
# Close outline if necessary.
if math.dist(self.outline[0], self.outline[-1]) > 1e-6:
yield self.outline[-1], self.outline[0], (None, (None, None))
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
outline = []
for p1, p2, (clockwise, center) in self.segments():
if clockwise is None:
outline.append(p1)
else:
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
max_error=max_error, clip_max_error=clip_max_error))
outline.pop() # remove arc end point
return type(self)(outline)
def bounding_box(self):
bbox = (None, None), (None, None)
for (x1, y1), (x2, y2), arc in self.segments:
if arc:
clockwise, (cx, cy) = arc
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
else:
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is None:
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
bbox = add_bounds(bbox, line_bounds)
else:
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
return bbox
@classmethod
@ -149,6 +176,9 @@ class ArcPoly(GraphicPrimitive):
color = fg if self.polarity_dark else bg
return tag('path', d=' '.join(self.path_d()), fill=color)
def to_arc_poly(self):
return self
@dataclass(frozen=True)
class Line(GraphicPrimitive):
@ -191,6 +221,24 @@ class Line(GraphicPrimitive):
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
fill='none', stroke=color, stroke_width=str(width))
def to_arc_poly(self):
l = math.dist((self.x1, self.y1), (self.x2, self.y2))
dx, dy = self.x2-self.x1, self.y2-self.y1
nx, ny = -dy/l, dx/l
rx, ry = nx*self.width/2, ny*self.width/2
return ArcPoly([
(self.x1+rx, self.y1+ry),
(self.x1-rx, self.y1-ry),
(self.x2-rx, self.y2-ry),
(self.x2+rx, self.y2+ry),
], [
(True, (self.x1, self.y1)),
None,
(True, (self.x2, self.y2)),
None,
])
@dataclass(frozen=True)
class Arc(GraphicPrimitive):
""" Circular arc with line width ``width`` going from ``(x1, y1)`` to ``(x2, y2)`` around center at ``(cx, cy)``. """
@ -202,9 +250,9 @@ class Arc(GraphicPrimitive):
x2 : float
#: End Y coodinate
y2 : float
#: Center X coordinate relative to ``x1``
#: Center X coordinate (absolute)
cx : float
#: Center Y coordinate relative to ``y1``
#: Center Y coordinate (absolute)
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
@ -214,11 +262,10 @@ class Arc(GraphicPrimitive):
@property
def is_circle(self):
return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2)
return math.isclose(self.x1, self.x2, abs_tol=1e-6) and math.isclose(self.y1, self.y2, abs_tol=1e-6)
def flip(self):
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1,
cx=(self.x1 + self.cx) - self.x2, cy=(self.y1 + self.cy) - self.y2, clockwise=not self.clockwise)
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, clockwise=not self.clockwise)
def bounding_box(self):
r = self.width/2
@ -232,6 +279,25 @@ class Arc(GraphicPrimitive):
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}',
fill='none', stroke=color, stroke_width=width)
def to_arc_poly(self):
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
return ArcPoly([
(self.x1+nx1, self.y1+nx1),
(self.x1-nx1, self.y1-nx1),
(self.x2-nx2, self.y2-nx2),
(self.x2+nx2, self.y2+nx2),
], [
(self.clockwise, (self.x1, self.y1)),
(self.clockwise, (self.cx, self.cy)),
(self.clockwise, (self.x2, self.y2)),
(self.clockwise, (self.cx, self.cy)),
])
@dataclass(frozen=True)
class Rectangle(GraphicPrimitive):
#: **Center** X coordinate

View file

@ -142,7 +142,7 @@ def _parse_path_d(path):
cx = mx - nx*nl
cy = my - ny*nl
(min_x, min_y), (max_x, max_y) = arc_bounds(last_x, last_y, ax, ay, cx-last_x, cy-last_y, clockwise=(not sweep))
(min_x, min_y), (max_x, max_y) = arc_bounds(last_x, last_y, ax, ay, cx, cy, clockwise=(not sweep))
min_x -= sr
min_y -= sr
max_x += sr

View file

@ -244,6 +244,59 @@ def rotate_point(x, y, angle, cx=0, cy=0):
cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle))
def sweep_angle(cx, cy, x1, y1, x2, y2, clockwise):
""" Calculate absolute sweep angle of arc. This is always a positive number.
:returns: Angle in clockwise radian between ``0`` and ``2*math.pi``
:rtype: float
"""
x1, y1 = x1-cx, y1-cy
x2, y2 = x2-cx, y2-cy
a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2)
f = abs(a2 - a1)
if not clockwise:
if a2 > a1:
return a2 - a1
else:
return 2*math.pi - abs(a2 - a1)
else:
if a1 > a2:
return a1 - a2
else:
return 2*math.pi - abs(a1 - a2)
def approximate_arc(cx, cy, x1, y1, x2, y2, clockwise, max_error=1e-2, clip_max_error=True):
# TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer
# results than necessary. Fix this.
r = math.dist((x1, y1), (cx, cy))
if clip_max_error:
# 1 - math.sqrt(1 - 0.5*math.sqrt(2))
max_error = min(max_error, r*0.4588038998538031)
elif max_error >= r:
yield (x1, y1)
yield (x2, y2)
return
# see https://www.mathopenref.com/sagitta.html
l = math.sqrt(r**2 - (r - max_error)**2)
angle_max = math.asin(l/r)
sweep_angle = sweep_angle(cx, cy, x1, y1, x2, y2, clockwise)
num_segments = math.ceil(sweep_angle / angle_max)
angle = sweep_angle / num_segments
if not clockwise:
angle = -angle
for i in range(num_segments + 1):
yield rotate_point(x1, y1, i*angle, cx, cy)
def min_none(a, b):
""" Like the ``min(..)`` builtin, but if either value is ``None``, returns the other. """
if a is None:
@ -340,11 +393,9 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
# 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.
# cx, cy are in absolute coordinates.
# Center arc on cx, cy
cx += x1
cy += y1
x1 -= cx
x2 -= cx
y1 -= cy
@ -461,25 +512,25 @@ def point_line_distance(l1, l2, p):
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).
""" Format an SVG circular arc "A" path data entry given an arc in Gerber notation (but with center in absolute
coordinates).
:rtype: str
"""
r = float(math.hypot(*center))
r = float(math.dist(old, 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]
intermediate = old[0] + 2*(center[0]-old[0]), old[1] + 2*(center[1]-old[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} {float(intermediate[0]):.6} {float(intermediate[1]):.6} ' +\
f'A {r:.6} {r:.6} 0 1 {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}'
else: # normal case
d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1]))
d = point_line_distance(old, new, center[0], center[1])
large_arc = int((d < 0) == clockwise)
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}'