gerbonara/gerbonara/graphic_primitives.py

334 wiersze
12 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
#
# 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 math
import itertools
from dataclasses import dataclass, replace, field
from .utils import *
prec = lambda x: f'{float(x):.6}'
@dataclass(frozen=True)
class GraphicPrimitive:
# hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY.
#
# For details, refer to graphic_objects.py
def __init_subclass__(cls):
cls.polarity_dark = True
d = {'polarity_dark': bool}
if hasattr(cls, '__annotations__'):
cls.__annotations__.update(d)
else:
cls.__annotations__ = d
def bounding_box(self):
""" Return the axis-aligned bounding box of this feature.
:returns: ``((min_x, min_Y), (max_x, max_y))``
:rtype: tuple
"""
raise NotImplementedError()
def to_svg(self, fg='black', bg='white', tag=Tag):
""" Render this primitive into its SVG representation.
: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.
:rtype: str
"""
raise NotImplementedError()
@dataclass(frozen=True)
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, fg='black', bg='white', tag=Tag):
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):
""" Polygon whose sides may be either straight lines or circular arcs. """
#: 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. 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, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise``
will be ``None``.
"""
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), (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
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 / n
return kls([
(x + math.cos(rotation + i*delta) * r,
y + math.sin(rotation + i*delta) * r)
for i in range(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):
if len(self.outline) == 0:
return
yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}'
for old, new, (clockwise, center) in self.segments:
if clockwise is None:
yield f'L {float(new[0]):.6} {float(new[1]):.6}'
else:
yield svg_arc(old, new, center, clockwise)
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()), fill=color)
def to_arc_poly(self):
return self
@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
#: line.
x1 : float
#: Start Y coordinate
y1 : float
#: End X coordinate
x2 : float
#: End Y coordinate
y2 : float
#: 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`. """
if w > h:
w, a, b = h, w-h, 0
else:
w, a, b = w, 0, h-w
return kls(
*rotate_point(x-a/2, y-b/2, rotation, x, y),
*rotate_point(x+a/2, y+b/2, rotation, x, y),
w, polarity_dark=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, 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 {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)``. """
#: Start X coodinate
x1 : float
#: Start Y coodinate
y1 : float
#: End X coodinate
x2 : float
#: End Y coodinate
y2 : float
#: Center X coordinate (absolute)
cx : float
#: 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
clockwise : bool
#: Line width of this arc.
width : float
@property
def is_circle(self):
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, clockwise=not self.clockwise)
def bounding_box(self):
r = self.width/2
(min_x, min_y), (max_x, max_y) = arc_bounds(self.x1, self.y1, self.x2, self.y2, self.cx, self.cy, self.clockwise)
return (min_x-r, min_y-r), (max_x+r, max_y+r)
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 {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
x : float
#: **Center** Y coordinate
y : float
#: width
w : float
#: height
h : float
#: rotation around center in radians
rotation : float
def bounding_box(self):
return self.to_arc_poly().bounding_box()
def to_arc_poly(self):
sin, cos = math.sin(self.rotation), math.cos(self.rotation)
sw, cw = sin*self.w/2, cos*self.w/2
sh, ch = sin*self.h/2, cos*self.h/2
x, y = self.x, self.y
return ArcPoly([
(x - (cw+sh), y - (ch+sw)),
(x - (cw+sh), y + (ch+sw)),
(x + (cw+sh), y + (ch+sw)),
(x + (cw+sh), y - (ch+sw)),
])
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=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h),
**svg_rotation(self.rotation, self.x, self.y), fill=color)