#!/usr/bin/env python # -*- coding: utf-8 -*- # Copyright 2019 Hiroshi Murayama import io, sys from math import pi, cos, sin, tan, atan, atan2, acos, asin, sqrt import dxfgrabber from gerber.cam import CamFile, FileSettings from gerber.utils import inch, metric, write_gerber_value, rotate_point from gerber.gerber_statements import ADParamStmt from gerber.excellon_statements import ExcellonTool from gerber.excellon_statements import CoordinateStmt from gerberex.utility import is_equal_point, is_equal_value from gerberex.dxf_path import generate_paths, judge_containment from gerberex.excellon import write_excellon_header from gerberex.rs274x import write_gerber_header ACCEPTABLE_ERROR = 0.001 def _normalize_angle(start_angle, end_angle): angle = end_angle - start_angle if angle > 0: start = start_angle % 360 else: angle = -angle start = end_angle % 360 angle = min(angle, 360) start = start - 360 if start > 180 else start regions = [] while angle > 0: end = start + angle if end <= 180: regions.append((start * pi / 180, end * pi / 180)) angle = 0 else: regions.append((start * pi / 180, pi)) angle = end - 180 start = -180 return regions def _intersections_of_line_and_circle(start, end, center, radius, error_range): x1 = start[0] - center[0] y1 = start[1] - center[1] x2 = end[0] - center[0] y2 = end[1] - center[1] dx = x2 - x1 dy = y2 - y1 dr = sqrt(dx * dx + dy * dy) D = x1 * y2 - x2 * y1 distance = abs(dy * x1 - dx * y1) / dr D2 = D * D dr2 = dr * dr r2 = radius * radius delta = r2 * dr2 - D2 if distance > radius - error_range and distance < radius + error_range: delta = 0 if delta < 0: return None sqrt_D = sqrt(delta) E_x = -dx * sqrt_D if dy < 0 else dx * sqrt_D E_y = abs(dy) * sqrt_D p1_x = (D * dy + E_x) / dr2 p2_x = (D * dy - E_x) / dr2 p1_y = (-D * dx + E_y) / dr2 p2_y = (-D * dx - E_y) / dr2 p1_angle = atan2(p1_y, p1_x) p2_angle = atan2(p2_y, p2_x) if dx == 0: p1_t = (p1_y - y1) / dy p2_t = (p2_y - y1) / dy else: p1_t = (p1_x - x1) / dx p2_t = (p2_x - x1) / dx if delta == 0: return ( (p1_x + center[0], p1_y + center[1]), None, p1_angle, None, p1_t, None ) else: return ( (p1_x + center[0], p1_y + center[1]), (p2_x + center[0], p2_y + center[1]), p1_angle, p2_angle, p1_t, p2_t ) class DxfStatement(object): def __init__(self, entity): self.entity = entity self.start = None self.end = None self.is_closed = False def to_inch(self): pass def to_metric(self): pass def is_equal_to(self, target, error_range=0): return False def reverse(self): raise Exception('Not implemented') def offset(self, offset_x, offset_y): raise Exception('Not supported') def rotate(self, angle, center=(0, 0)): raise Exception('Not supported') class DxfLineStatement(DxfStatement): @classmethod def from_entity(cls, entity): start = (entity.start[0], entity.start[1]) end = (entity.end[0], entity.end[1]) return cls(entity, start, end) @property def bounding_box(self): return (min(self.start[0], self.end[0]), min(self.start[1], self.end[1]), max(self.start[0], self.end[0]), max(self.start[1], self.end[1])) def __init__(self, entity, start, end): super(DxfLineStatement, self).__init__(entity) self.start = start self.end = end def to_inch(self): self.start = ( inch(self.start[0]), inch(self.start[1])) self.end = ( inch(self.end[0]), inch(self.end[1])) def to_metric(self): self.start = ( metric(self.start[0]), metric(self.start[1])) self.end = ( metric(self.end[0]), metric(self.end[1])) def is_equal_to(self, target, error_range=0): if not isinstance(target, DxfLineStatement): return False return (is_equal_point(self.start, target.start, error_range) and \ is_equal_point(self.end, target.end, error_range)) or \ (is_equal_point(self.start, target.end, error_range) and \ is_equal_point(self.end, target.start, error_range)) def reverse(self): pt = self.start self.start = self.end self.end = pt def dots(self, pitch, width, offset=0): x0, y0 = self.start x1, y1 = self.end y1 = self.end[1] xp = x1 - x0 yp = y1 - y0 l = sqrt(xp * xp + yp * yp) xd = xp * pitch / l yd = yp * pitch / l x0 += xp * offset / l y0 += yp * offset / l if offset > l + width / 2: return (None, offset - l) else: d = offset; while d < l + width / 2: yield ((x0, y0), d - l) x0 += xd y0 += yd d += pitch def offset(self, offset_x, offset_y): self.start = (self.start[0] + offset_x, self.start[1] + offset_y) self.end = (self.end[0] + offset_x, self.end[1] + offset_y) def rotate(self, angle, center=(0, 0)): self.start = rotate_point(self.start, angle, center) self.end = rotate_point(self.end, angle, center) def intersections_with_halfline(self, point_from, point_to, error_range): denominator = (self.end[0] - self.start[0]) * (point_to[1] - point_from[1]) - \ (self.end[1] - self.start[1]) * (point_to[0] - point_from[0]) de = error_range * error_range if denominator >= -de and denominator <= de: return [] from_dx = point_from[0] - self.start[0] from_dy = point_from[1] - self.start[1] r = ((point_to[1] - point_from[1]) * from_dx - (point_to[0] - point_from[0]) * from_dy) / denominator s = ((self.end[1] - self.start[1]) * from_dx - (self.end[0] - self.start[0]) * from_dy) / denominator dx = (self.end[0] - self.start[0]) dy = (self.end[1] - self.start[1]) le = error_range / sqrt(dx * dx + dy * dy) if s < 0 or r < -le or r > 1 + le: return [] pt = (self.start[0] + (self.end[0] - self.start[0]) * r, self.start[1] + (self.end[1] - self.start[1]) * r) if is_equal_point(pt, self.start, error_range): return [] else: return [pt] def intersections_with_arc(self, center, radius, angle_regions, error_range): intersection = \ _intersections_of_line_and_circle(self.start, self.end, center, radius, error_range) if intersection is None: return [] else: p1, p2, p1_angle, p2_angle, p1_t, p2_t = intersection pts = [] if p1_t >= 0 and p1_t <= 1: for region in angle_regions: if p1_angle >= region[0] and p1_angle <= region[1]: pts.append(p1) break if p2 is not None and p2_t >= 0 and p2_t <= 1: for region in angle_regions: if p2_angle >= region[0] and p2_angle <= region[1]: pts.append(p2) break return pts class DxfArcStatement(DxfStatement): def __init__(self, entity): super(DxfArcStatement, self).__init__(entity) if entity.dxftype == 'CIRCLE': self.radius = self.entity.radius self.center = (self.entity.center[0], self.entity.center[1]) self.start = (self.center[0] + self.radius, self.center[1]) self.end = self.start self.start_angle = 0 self.end_angle = 360 self.is_closed = True elif entity.dxftype == 'ARC': self.start_angle = self.entity.start_angle self.end_angle = self.entity.end_angle self.radius = self.entity.radius self.center = (self.entity.center[0], self.entity.center[1]) self.start = ( self.center[0] + self.radius * cos(self.start_angle / 180. * pi), self.center[1] + self.radius * sin(self.start_angle / 180. * pi), ) self.end = ( self.center[0] + self.radius * cos(self.end_angle / 180. * pi), self.center[1] + self.radius * sin(self.end_angle / 180. * pi), ) angle = self.end_angle - self.start_angle self.is_closed = angle >= 360 or angle <= -360 else: raise Exception('invalid DXF type was specified') self.angle_regions = _normalize_angle(self.start_angle, self.end_angle) @property def bounding_box(self): return (self.center[0] - self.radius, self.center[1] - self.radius, self.center[0] + self.radius, self.center[1] + self.radius) def to_inch(self): self.radius = inch(self.radius) self.center = (inch(self.center[0]), inch(self.center[1])) self.start = (inch(self.start[0]), inch(self.start[1])) self.end = (inch(self.end[0]), inch(self.end[1])) def to_metric(self): self.radius = metric(self.radius) self.center = (metric(self.center[0]), metric(self.center[1])) self.start = (metric(self.start[0]), metric(self.start[1])) self.end = (metric(self.end[0]), metric(self.end[1])) def is_equal_to(self, target, error_range=0): if not isinstance(target, DxfArcStatement): return False aerror_range = error_range / pi * self.radius * 180 return is_equal_point(self.center, target.center, error_range) and \ is_equal_value(self.radius, target.radius, error_range) and \ ((is_equal_value(self.start_angle, target.start_angle, aerror_range) and is_equal_value(self.end_angle, target.end_angle, aerror_range)) or (is_equal_value(self.start_angle, target.end_angle, aerror_range) and is_equal_value(self.end_angle, target.end_angle, aerror_range))) def reverse(self): tmp = self.start_angle self.start_angle = self.end_angle self.end_angle = tmp tmp = self.start self.start = self.end self.end = tmp def dots(self, pitch, width, offset=0): angle = self.end_angle - self.start_angle afactor = 1 if angle > 0 else -1 aangle = angle * afactor L = 2 * pi * self.radius l = L * aangle / 360 pangle = pitch / L * 360 wangle = width / L * 360 oangle = offset / L * 360 if offset > l + width / 2: yield (None, offset - l) else: da = oangle while da < aangle + wangle / 2: cangle = self.start_angle + da * afactor x = self.radius * cos(cangle / 180 * pi) + self.center[0] y = self.radius * sin(cangle / 180 * pi) + self.center[1] remain = (da - aangle) / 360 * L yield((x, y), remain) da += pangle def offset(self, offset_x, offset_y): self.center = (self.center[0] + offset_x, self.center[1] + offset_y) self.start = (self.start[0] + offset_x, self.start[1] + offset_y) self.end = (self.end[0] + offset_x, self.end[1] + offset_y) def rotate(self, angle, center=(0, 0)): self.start_angle += angle self.end_angle += angle self.center = rotate_point(self.center, angle, center) self.start = rotate_point(self.start, angle, center) self.end = rotate_point(self.end, angle, center) self.angle_regions = _normalize_angle(self.start_angle, self.end_angle) def intersections_with_halfline(self, point_from, point_to, error_range): intersection = \ _intersections_of_line_and_circle( point_from, point_to, self.center, self.radius, error_range) if intersection is None: return [] else: p1, p2, p1_angle, p2_angle, p1_t, p2_t = intersection if is_equal_point(p1, self.start, error_range): p1 = None elif p2 is not None and is_equal_point(p2, self.start, error_range): p2 = None def is_contained(angle, region, error): if angle >= region[0] - error and angle <= region[1] + error: return True if angle < 0 and region[1] > 0: angle = angle + 2 * pi elif angle > 0 and region[0] < 0: angle = angle - 2 * pi return angle >= region[0] - error and angle <= region[1] + error aerror = error_range * self.radius pts = [] if p1 is not None and p1_t >= 0 and not is_equal_point(p1, self.start, error_range): for region in self.angle_regions: if is_contained(p1_angle, region, aerror): pts.append(p1) break if p2 is not None and p2_t >= 0 and not is_equal_point(p2, self.start, error_range): for region in self.angle_regions: if is_contained(p2_angle, region, aerror): pts.append(p2) break return pts def intersections_with_arc(self, center, radius, angle_regions, error_range): x1 = center[0] - self.center[0] y1 = center[1] - self.center[1] r1 = self.radius r2 = radius cd_sq = x1 * x1 + y1 * y1 cd = sqrt(cd_sq) rd = abs(r1 - r2) if (cd >= 0 and cd <= rd) or cd >= r1 + r2: return [] A = (cd_sq + r1 * r1 - r2 * r2) / 2 scale = sqrt(cd_sq * r1 * r1 - A * A) / cd_sq xl = A * x1 / cd_sq xr = y1 * scale yl = A * y1 / cd_sq yr = x1 * scale pt1_x = xl + xr pt1_y = yl - yr pt2_x = xl - xr pt2_y = yl + yr pt1_angle1 = atan2(pt1_y, pt1_x) pt1_angle2 = atan2(pt1_y - y1, pt1_x - x1) pt2_angle1 = atan2(pt2_y, pt2_x) pt2_angle2 = atan2(pt2_y - y1, pt2_x - x1) aerror = error_range * self.radius pts=[] for region in self.angle_regions: if pt1_angle1 >= region[0] and pt1_angle1 <= region[1]: for region in angle_regions: if pt1_angle2 >= region[0] - aerror and pt1_angle2 <= region[1] + aerror: pts.append((pt1_x + self.center[0], pt1_y + self.center[1])) break break for region in self.angle_regions: if pt2_angle1 >= region[0] and pt2_angle1 <= region[1]: for region in angle_regions: if pt2_angle2 >= region[0] - aerror and pt2_angle2 <= region[1] + aerror: pts.append((pt2_x + self.center[0], pt2_y + self.center[1])) break break return pts class DxfPolylineStatement(DxfStatement): def __init__(self, entity): super(DxfPolylineStatement, self).__init__(entity) self.start = (self.entity.points[0][0], self.entity.points[0][1]) self.is_closed = self.entity.is_closed if self.is_closed: self.end = self.start else: self.end = (self.entity.points[-1][0], self.entity.points[-1][1]) def disassemble(self): class Item: pass def ptseq(): for i in range(1, len(self.entity.points)): yield i if self.entity.is_closed: yield 0 x0 = self.entity.points[0][0] y0 = self.entity.points[0][1] b = self.entity.bulge[0] for idx in ptseq(): pt = self.entity.points[idx] x1 = pt[0] y1 = pt[1] if b == 0: item = Item() item.dxftype = 'LINE' item.start = (x0, y0) item.end = (x1, y1) item.is_closed = False yield DxfLineStatement.from_entity(item) else: ang = 4 * atan(b) xm = x0 + x1 ym = y0 + y1 t = 1 / tan(ang / 2) xc = (xm - t * (y1 - y0)) / 2 yc = (ym + t * (x1 - x0)) / 2 r = sqrt((x0 - xc)*(x0 - xc) + (y0 - yc)*(y0 - yc)) rx0 = x0 - xc ry0 = y0 - yc rc = max(min(rx0 / r, 1.0), -1.0) start_angle = acos(rc) if ry0 > 0 else 2 * pi - acos(rc) start_angle *= 180 / pi end_angle = start_angle + ang * 180 / pi item = Item() item.dxftype = 'ARC' item.start = (x0, y0) item.end = (x1, y1) item.start_angle = start_angle item.end_angle = end_angle item.radius = r item.center = (xc, yc) item.is_closed = end_angle - start_angle >= 360 yield DxfArcStatement(item) x0 = x1 y0 = y1 b = self.entity.bulge[idx] def to_inch(self): self.start = (inch(self.start[0]), inch(self.start[1])) self.end = (inch(self.end[0]), inch(self.end[1])) for idx in range(0, len(self.entity.points)): self.entity.points[idx] = ( inch(self.entity.points[idx][0]), inch(self.entity.points[idx][1])) def to_metric(self): self.start = (metric(self.start[0]), metric(self.start[1])) self.end = (metric(self.end[0]), metric(self.end[1])) for idx in range(0, len(self.entity.points)): self.entity.points[idx] = ( metric(self.entity.points[idx][0]), metric(self.entity.points[idx][1])) def offset(self, offset_x, offset_y): for idx in range(len(self.entity.points)): self.entity.points[idx] = ( self.entity.points[idx][0] + offset_x, self.entity.points[idx][1] + offset_y) def rotate(self, angle, center=(0, 0)): for idx in range(len(self.entity.points)): self.entity.points[idx] = rotate_point(self.entity.points[idx], angle, center) class DxfStatements(object): def __init__(self, statements, units, dcode=10, draw_mode=None, fill_mode=None): if draw_mode is None: draw_mode = DxfFile.DM_LINE if fill_mode is None: fill_mode = DxfFile.FM_TURN_OVER self._units = units self.dcode = dcode self.draw_mode = draw_mode self.fill_mode = fill_mode self.pitch = inch(1) if self._units == 'inch' else 1 self.width = 0 self.error_range = inch(ACCEPTABLE_ERROR) if self._units == 'inch' else ACCEPTABLE_ERROR self.statements = list(filter( lambda i: not (isinstance(i, DxfLineStatement) and \ is_equal_point(i.start, i.end, self.error_range)), statements )) self.close_paths, self.open_paths = generate_paths(self.statements, self.error_range) self.sorted_close_paths = [] self.polarity = True # True means dark, False means clear @property def units(self): return _units def _polarity_command(self, polarity=None): if polarity is None: polarity = self.polarity return '%LPD*%' if polarity else '%LPC*%' def _prepare_sorted_close_paths(self): if self.sorted_close_paths: return for i in range(0, len(self.close_paths)): for j in range(i + 1, len(self.close_paths)): containee, container = judge_containment( self.close_paths[i], self.close_paths[j], self.error_range) if containee is not None: containee.containers.append(container) self.sorted_close_paths = sorted(self.close_paths, key=lambda path: len(path.containers)) def to_gerber(self, settings=FileSettings()): def gerbers(): yield 'G75*' yield self._polarity_command() yield 'D{0}*'.format(self.dcode) if self.draw_mode == DxfFile.DM_FILL: yield 'G36*' if self.fill_mode == DxfFile.FM_TURN_OVER: self._prepare_sorted_close_paths() polarity = self.polarity level = 0 for path in self.sorted_close_paths: if len(path.containers) > level: level = len(path.containers) polarity = not polarity yield 'G37*' yield self._polarity_command(polarity) yield 'G36*' yield path.to_gerber(settings) else: for path in self.close_paths: yield path.to_gerber(settings) yield 'G37*' else: pitch = self.pitch if self.draw_mode == DxfFile.DM_MOUSE_BITES else 0 for path in self.open_paths: yield path.to_gerber(settings, pitch=pitch, width=self.width) for path in self.close_paths: yield path.to_gerber(settings, pitch=pitch, width=self.width) return '\n'.join(gerbers()) def to_excellon(self, settings=FileSettings()): if self.draw_mode == DxfFile.DM_FILL: return def drills(): pitch = self.pitch if self.draw_mode == DxfFile.DM_MOUSE_BITES else 0 for path in self.open_paths: yield path.to_excellon(settings, pitch=pitch, width=self.width) for path in self.close_paths: yield path.to_excellon(settings, pitch=pitch, width=self.width) return '\n'.join(drills()) def to_inch(self): if self._units == 'metric': self._units = 'inch' self.pitch = inch(self.pitch) self.width = inch(self.width) self.error_range = inch(self.error_range) for path in self.open_paths: path.to_inch() for path in self.close_paths: path.to_inch() def to_metric(self): if self._units == 'inch': self._units = 'metric' self.pitch = metric(self.pitch) self.width = metric(self.width) self.error_range = metric(self.error_range) for path in self.open_paths: path.to_metric() for path in self.close_paths: path.to_metric() def offset(self, offset_x, offset_y): for path in self.open_paths: path.offset(offset_x, offset_y) for path in self.close_paths: path.offset(offset_x, offset_y) def rotate(self, angle, center=(0, 0)): for path in self.open_paths: path.rotate(angle, center) for path in self.close_paths: path.rotate(angle, center) class DxfFile(CamFile): DM_LINE = 0 DM_FILL = 1 DM_MOUSE_BITES = 2 FM_SIMPLE = 0 FM_TURN_OVER = 1 FT_RX274X = 0 FT_EXCELLON = 1 @classmethod def from_dxf(cls, dxf, settings=None, draw_mode=None, filename=None): fsettings = settings if settings else \ FileSettings(zero_suppression='leading') if dxf.header['$INSUNITS'] == 1: fsettings.units = 'inch' if not settings: fsettings.format = (2, 5) else: fsettings.units = 'metric' if not settings: fsettings.format = (3, 4) statements = [] for entity in dxf.entities: if entity.dxftype == 'LWPOLYLINE': statements.append(DxfPolylineStatement(entity)) elif entity.dxftype == 'LINE': statements.append(DxfLineStatement.from_entity(entity)) elif entity.dxftype == 'CIRCLE': statements.append(DxfArcStatement(entity)) elif entity.dxftype == 'ARC': statements.append(DxfArcStatement(entity)) return cls(statements, fsettings, draw_mode, filename) @classmethod def rectangle(cls, width, height, left=0, bottom=0, units='metric', draw_mode=None, filename=None): if units == 'metric': settings = FileSettings(units=units, zero_suppression='leading', format=(3,4)) else: settings = FileSettings(units=units, zero_suppression='leading', format=(2,5)) statements = [ DxfLineStatement(None, (left, bottom), (left + width, bottom)), DxfLineStatement(None, (left + width, bottom), (left + width, bottom + height)), DxfLineStatement(None, (left + width, bottom + height), (left, bottom + height)), DxfLineStatement(None, (left, bottom + height), (left, bottom)), ] return cls(statements, settings, draw_mode, filename) def __init__(self, statements, settings=None, draw_mode=None, filename=None): if not settings: settings = FileSettings(units='metric', format=(3,4), zero_suppression='leading') if draw_mode == None: draw_mode = self.DM_LINE super(DxfFile, self).__init__(settings=settings, filename=filename) self._draw_mode = draw_mode self._fill_mode = self.FM_TURN_OVER self.aperture = ADParamStmt.circle(dcode=10, diameter=0.0) if settings.units == 'inch': self.aperture.to_inch() else: self.aperture.to_metric() self.statements = DxfStatements( statements, self.units, dcode=self.aperture.d, draw_mode=self.draw_mode, fill_mode=self.filename) @property def dcode(self): return self.aperture.dcode @dcode.setter def dcode(self, value): self.aperture.d = value self.statements.dcode = value @property def width(self): return self.aperture.modifiers[0][0] @width.setter def width(self, value): self.aperture.modifiers = ([float(value),],) self.statements.width = value @property def draw_mode(self): return self._draw_mode @draw_mode.setter def draw_mode(self, value): self._draw_mode = value self.statements.draw_mode = value @property def fill_mode(self): return self._fill_mode @fill_mode.setter def fill_mode(self, value): self._fill_mode = value self.statements.fill_mode = value @property def pitch(self): return self.statements.pitch @pitch.setter def pitch(self, value): self.statements.pitch = value def write(self, filename=None, filetype=FT_RX274X): self.settings.notation = 'absolute' self.settings.zeros = 'trailing' filename = filename if filename is not None else self.filename with open(filename, 'w') as f: if filetype == self.FT_RX274X: write_gerber_header(f, self.settings) f.write(self.aperture.to_gerber(self.settings) + '\n') f.write(self.statements.to_gerber(self.settings) + '\n') f.write('M02*\n') else: tools = [ExcellonTool(self.settings, number=1, diameter=self.width)] write_excellon_header(f, self.settings, tools) f.write('T01\n') f.write(self.statements.to_excellon(self.settings) + '\n') f.write('M30\n') def to_inch(self): if self.units == 'metric': self.aperture.to_inch() self.statements.to_inch() self.pitch = inch(self.pitch) self.units = 'inch' def to_metric(self): if self.units == 'inch': self.aperture.to_metric() self.statements.to_metric() self.pitch = metric(self.pitch) self.units = 'metric' def offset(self, offset_x, offset_y): self.statements.offset(offset_x, offset_y) def rotate(self, angle, center=(0, 0)): self.statements.rotate(angle, center) def negate_polarity(self): self.statements.polarity = not self.statements.polarity def loads(data, filename=None): if sys.version_info.major == 2: data = unicode(data) stream = io.StringIO(data) dxf = dxfgrabber.read(stream) return DxfFile.from_dxf(dxf)