Add beginnings of CAD module
This commit is contained in:
parent
909766a3a0
commit
8d4430ea61
5 changed files with 66069 additions and 0 deletions
0
gerbonara/cad/__init__.py
Normal file
0
gerbonara/cad/__init__.py
Normal file
233
gerbonara/cad/primitives.py
Normal file
233
gerbonara/cad/primitives.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
|
||||
import math
|
||||
from copy import copy
|
||||
from itertools import zip_longest
|
||||
from dataclasses import dataclass, field, KW_ONLY
|
||||
|
||||
from ..utils import LengthUnit, MM, rotate_point
|
||||
from ..layers import LayerStack
|
||||
from ..graphic_objects import Line, Arc, Flash
|
||||
from ..apertures import Aperture, CircleAperture, RectangleAperture, ExcellonTool
|
||||
|
||||
|
||||
def sgn(x):
|
||||
return -1 if x < 0 else 1
|
||||
|
||||
|
||||
class Board:
|
||||
def __init__(self):
|
||||
self.objects = set()
|
||||
|
||||
def to_layer_stack(self, layer_stack):
|
||||
if layer_stack is None:
|
||||
layer_stack = LayerStack()
|
||||
|
||||
for obj in self.objects:
|
||||
obj.render(stack)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Positioned:
|
||||
x: float
|
||||
y: float
|
||||
_: KW_ONLY
|
||||
rotation: float = 0.0
|
||||
unit: LengthUnit = MM
|
||||
parent: object = None
|
||||
|
||||
@property
|
||||
def abs_pos(self, dx, dy, da):
|
||||
x, y = rotate_point(self.x, self.y, da)
|
||||
|
||||
if self.parent is None:
|
||||
px, py, pa = dx, dy, 0
|
||||
else:
|
||||
px, py, pa = self.parent.abs_pos(dx, dy, da)
|
||||
|
||||
return x+px, y+py, self.rotation+da+pa
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pad(Positioned):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class SMDPad(Pad):
|
||||
copper_aperture: Aperture
|
||||
mask_aperture: Aperture
|
||||
paste_aperture: Aperture
|
||||
silk_features: list
|
||||
side: str = 'top'
|
||||
|
||||
def to_layer_stack(self, layer_stack):
|
||||
x, y, rotation = self.abs_pos
|
||||
stack[self.side, 'copper'].objects.append(Flash(x, y, self.copper_aperture.rotated(rotation), unit=self.unit))
|
||||
stack[self.side, 'mask' ].objects.append(Flash(x, y, self.mask_aperture.rotated(rotation), unit=self.unit))
|
||||
stack[self.side, 'paste' ].objects.append(Flash(x, y, self.paste_aperture.rotated(rotation), unit=self.unit))
|
||||
stack[self.side, 'silk' ].objects.extend([copy(feature).rotate(rotation).offset(x, y, self.unit)
|
||||
for feature in self.silk_features])
|
||||
|
||||
def flip(self):
|
||||
self.side = 'top' if self.side == 'bottom' else 'top'
|
||||
|
||||
|
||||
class THTPad(Pad):
|
||||
drill_dia: float
|
||||
pad_top: SMDPad
|
||||
pad_bottom: SMDPad = None
|
||||
aperture_inner: Aperture = None
|
||||
plated: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
if self.pad_bottom is None:
|
||||
self.pad_bottom = copy(self.pad_top)
|
||||
self.pad_bottom.flip()
|
||||
|
||||
self.pad_top.parent = self.pad_bottom.parent = self
|
||||
|
||||
if (self.pad_top.side, self.pad_bottom.side) != ('top', 'bottom'):
|
||||
raise ValueError(f'The top and bottom pads must have side set to top and bottom, respectively. Currently, the top pad side is set to {self.pad_top.side} and the bottom pad side to {self.pad_bottom.side}.')
|
||||
|
||||
def to_layer_stack(self, layer_stack, x, y, rotation):
|
||||
x, y, rotation = self.abs_pos
|
||||
self.top_pad.to_layer_stack(layer_stack)
|
||||
self.bottom_pad.to_layer_stack(layer_stack)
|
||||
|
||||
for (side, use), layer in layer_stack.inner_layers:
|
||||
layer.objects.append(Flash(x, y, self.aperture_inner.rotated(rotation), unit=self.unit))
|
||||
|
||||
hole = Flash(self.x, self.y, ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), unit=self.unit)
|
||||
if self.plated:
|
||||
layer_stack.drill_pth.objects.append(hole)
|
||||
else:
|
||||
layer_stack.drill_npth.objects.append(hole)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Via(Positioned):
|
||||
diameter: float
|
||||
hole: float
|
||||
|
||||
def to_layer_stack(self, layer_stack):
|
||||
x, y, rotation = self.abs_pos
|
||||
|
||||
aperture = CircleAperture(diameter=self.diameter, unit=self.unit)
|
||||
tool = ExcellonTool(diameter=self.hole, unit=self.unit)
|
||||
|
||||
for (side, use), layer in layer_stack.copper_layers:
|
||||
layer.objects.append(Flash(x, y, aperture, unit=self.unit))
|
||||
|
||||
layer_stack.drill_pth.objects.append(Flash(x, y, tool, unit=self.unit))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace:
|
||||
width: float
|
||||
start: object = None
|
||||
end: object = None
|
||||
side: str = 'top'
|
||||
waypoints: [(float, float)] = field(default_factory=list)
|
||||
style: str = 'direct'
|
||||
orientation: [str] = tuple() # 'top' or 'bottom'
|
||||
roundover: float = 0
|
||||
unit: LengthUnit = MM
|
||||
parent: object = None
|
||||
|
||||
DIRECT = 'direct'
|
||||
OBLIQUE = 'oblique'
|
||||
ORTHO = 'ortho'
|
||||
|
||||
CW = 'cw'
|
||||
CCW = 'ccw'
|
||||
|
||||
def _route(self, p1, p2, orientation):
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
dx = x2-x1
|
||||
dy = y2-y1
|
||||
|
||||
yield p1
|
||||
|
||||
if self.style == 'direct' or \
|
||||
math.isclose(x1, x2, abs_tol=1e-6) or math.isclose(y1, y2, abs_tol=1e-6) or \
|
||||
(self.style == 'oblique' and math.isclose(dx, dy, abs_tol=1e-6)):
|
||||
yield p2
|
||||
return
|
||||
|
||||
p = (abs(dy) > abs(dx)) == ((dx >= 0) == (dy >= 0))
|
||||
if self.style == 'oblique':
|
||||
if p == (orientation == 'cw'):
|
||||
if abs(dy) > abs(dx):
|
||||
yield (0, sgn(dy)*(abs(dy)-abs(dx)))
|
||||
else:
|
||||
yield (sgn(dx)*(abs(dx)-abs(dy)), 0)
|
||||
else:
|
||||
if abs(dy) > abs(dx):
|
||||
yield (dx, sgn(dy)*abs(dx))
|
||||
else:
|
||||
yield (sgn(dx)*abs(dy), dy)
|
||||
else: # self.style == 'ortho'
|
||||
pass
|
||||
#if p == (orientation == 'cw'):
|
||||
|
||||
#else:
|
||||
|
||||
yield p2
|
||||
|
||||
def to_layer_stack(self, layer_stack, x, y, rotation):
|
||||
start, end = self.start, self.end
|
||||
|
||||
if not isinstance(start, tuple):
|
||||
start = start.abs_pos
|
||||
if not isinstance(end, tuple):
|
||||
end = end.abs_pos
|
||||
|
||||
points = [start, *self.waypoints, end]
|
||||
aperture = CircleAperture(diameter=self.width, unit=self.unit)
|
||||
|
||||
for p1, p2, orientation in zip_longest(points[:-1], points[1:], self.orientation):
|
||||
layer_stack[self.side, 'copper'].extend(self._route(p1, p2, orientation, aperture))
|
||||
|
||||
if __name__ == '__main__':
|
||||
from ..utils import setup_svg, Tag
|
||||
from ..newstroke import Newstroke
|
||||
|
||||
font = Newstroke()
|
||||
|
||||
tags = []
|
||||
for n in range(0, 8*6):
|
||||
theta = 2*math.pi / (8*6) * n
|
||||
dx, dy = math.cos(theta), math.sin(theta)
|
||||
tr = Trace(0.1, style='oblique')
|
||||
points_cw = list(tr._route((0, 0), (dx, dy), 'cw'))
|
||||
points_ccw = list(tr._route((0, 0), (dx, dy), 'ccw'))
|
||||
|
||||
pd = lambda points: f'M {points[0][0]}, {points[0][1]} ' + ' '.join(f'L {x}, {y}' for x, y in points[1:])
|
||||
|
||||
strokes = list(font.render(f'α={n/(8*6)*360}', size=0.2))
|
||||
xs = [x for st in strokes for x, _y in st]
|
||||
ys = [y for st in strokes for _x, y in st]
|
||||
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
xf = f'translate({n//6*1.1 + 0.1} {n%6*1.3 + 0.3}) scale(0.5 0.5) translate(1 1)'
|
||||
txf = f'{xf} translate(0 -1.2) translate({-(max_x-min_x)/2} {-max_y})'
|
||||
|
||||
tags.append(Tag('circle', cx='0', cy='0', r='1',
|
||||
fill='none', stroke='black', opacity='0.5', stroke_width='0.05',
|
||||
transform=xf))
|
||||
tags.append(Tag('path',
|
||||
fill='none',
|
||||
stroke='red', opacity='0.5', stroke_width='0.05', stroke_linecap='round',
|
||||
transform=xf, d=pd(points_cw)))
|
||||
tags.append(Tag('path',
|
||||
fill='none',
|
||||
stroke='blue', opacity='0.5', stroke_width='0.05', stroke_linecap='round',
|
||||
transform=xf, d=pd(points_ccw)))
|
||||
tags.append(Tag('path',
|
||||
fill='none',
|
||||
stroke='black', opacity='0.5', stroke_width='0.02', stroke_linejoin='round', stroke_linecap='round',
|
||||
transform=txf, d=' '.join(pd(points) for points in strokes)))
|
||||
|
||||
print(setup_svg([Tag('g', tags, transform='scale(20 20)')], [(0, 0), (20*10*1.1 + 0.1, 20*10*1.3 + 0.1)]))
|
||||
|
||||
|
||||
0
gerbonara/data/__init__.py
Normal file
0
gerbonara/data/__init__.py
Normal file
65743
gerbonara/data/newstroke_font.cpp
Normal file
65743
gerbonara/data/newstroke_font.cpp
Normal file
File diff suppressed because it is too large
Load diff
93
gerbonara/newstroke.py
Normal file
93
gerbonara/newstroke.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
from pathlib import Path
|
||||
import unicodedata
|
||||
import re
|
||||
import ast
|
||||
from importlib.resources import files
|
||||
|
||||
from . import data
|
||||
|
||||
|
||||
STROKE_FONT_SCALE = 1/21
|
||||
FONT_OFFSET = -10
|
||||
DEFAULT_SPACE_WIDTH = 0.6
|
||||
DEFAULT_CHAR_GAP = 0.2
|
||||
|
||||
_dec = lambda c: ord(c)-ord('R')
|
||||
|
||||
|
||||
class Newstroke:
|
||||
def __init__(self, newstroke_cpp=None):
|
||||
if newstroke_cpp is None:
|
||||
newstroke_cpp = files(data).joinpath('newstroke_font.cpp').read_bytes()
|
||||
self.glyphs = dict(self.load(newstroke_cpp))
|
||||
|
||||
def render(self, text, size=1.0, space_width=DEFAULT_SPACE_WIDTH, char_gap=DEFAULT_CHAR_GAP):
|
||||
text = unicodedata.normalize('NFC', text)
|
||||
missing_glyph = self.glyphs['?']
|
||||
x = 0
|
||||
for c in text:
|
||||
if c == ' ':
|
||||
x += space_width*size
|
||||
continue
|
||||
|
||||
width, strokes = self.glyphs.get(c, missing_glyph)
|
||||
glyph_w = max(width, max(x for st in strokes for x, _y in st))
|
||||
|
||||
for st in strokes:
|
||||
yield self.transform_stroke(st, translate=(x, 0), scale=(size, size))
|
||||
|
||||
x += glyph_w*size
|
||||
|
||||
@classmethod
|
||||
def transform_stroke(kls, stroke, translate, scale):
|
||||
dx, dy = translate
|
||||
sx, sy = scale
|
||||
return [(x*sx+dx, y*sy+dy) for x, y in stroke]
|
||||
|
||||
|
||||
def load(self, newstroke_cpp):
|
||||
e = []
|
||||
for char, (width, strokes) in self.load_glyphs(newstroke_cpp):
|
||||
yield char, (width, strokes)
|
||||
|
||||
@classmethod
|
||||
def decode_stroke(kls, stroke, start_x):
|
||||
for i in range(0, len(stroke), 2):
|
||||
x = (stroke[i]-0x52-start_x)*STROKE_FONT_SCALE
|
||||
y = (stroke[i+1]-0x52+FONT_OFFSET)*STROKE_FONT_SCALE
|
||||
yield (x, y)
|
||||
|
||||
@classmethod
|
||||
def decode_glyph(kls, data):
|
||||
start_x, end_x = data[0]-0x52, data[1]-0x52
|
||||
width = end_x - start_x
|
||||
|
||||
strokes = tuple(tuple(kls.decode_stroke(st, start_x)) for st in data[2:].split(b' R'))
|
||||
return width*STROKE_FONT_SCALE, strokes
|
||||
|
||||
@classmethod
|
||||
def load_glyphs(kls, newstroke_cpp):
|
||||
it = iter(newstroke_cpp.splitlines())
|
||||
|
||||
for line in it:
|
||||
if re.search(rb'char.*\*', line):
|
||||
break
|
||||
|
||||
charcode = 0x20
|
||||
for line in it:
|
||||
if (match := re.search(rb'".*"', line)):
|
||||
yield chr(charcode), kls.decode_glyph(match.group(0)[1:-1].replace(b'\\\\', b'\\'))
|
||||
charcode += 1
|
||||
else:
|
||||
if b'}' in line:
|
||||
break
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import time
|
||||
t1 = time.time()
|
||||
Newstroke()
|
||||
t2 = time.time()
|
||||
print((t2-t1)*1000)
|
||||
Loading…
Add table
Add a link
Reference in a new issue