camlib.py 87 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543
  1. ############################################################
  2. # FlatCAM: 2D Post-processing for Manufacturing #
  3. # http://caram.cl/software/flatcam #
  4. # Author: Juan Pablo Caram (c) #
  5. # Date: 2/5/2014 #
  6. # MIT Licence #
  7. ############################################################
  8. import traceback
  9. from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos
  10. from matplotlib.figure import Figure
  11. import re
  12. # See: http://toblerity.org/shapely/manual.html
  13. from shapely.geometry import Polygon, LineString, Point, LinearRing
  14. from shapely.geometry import MultiPoint, MultiPolygon
  15. from shapely.geometry import box as shply_box
  16. from shapely.ops import cascaded_union
  17. import shapely.affinity as affinity
  18. from shapely.wkt import loads as sloads
  19. from shapely.wkt import dumps as sdumps
  20. from shapely.geometry.base import BaseGeometry
  21. # Used for solid polygons in Matplotlib
  22. from descartes.patch import PolygonPatch
  23. import simplejson as json
  24. # TODO: Commented for FlatCAM packaging with cx_freeze
  25. #from matplotlib.pyplot import plot
  26. import logging
  27. log = logging.getLogger('base2')
  28. log.setLevel(logging.DEBUG)
  29. #log.setLevel(logging.WARNING)
  30. #log.setLevel(logging.INFO)
  31. formatter = logging.Formatter('[%(levelname)s] %(message)s')
  32. handler = logging.StreamHandler()
  33. handler.setFormatter(formatter)
  34. log.addHandler(handler)
  35. class Geometry(object):
  36. """
  37. Base geometry class.
  38. """
  39. def __init__(self):
  40. # Units (in or mm)
  41. self.units = 'in'
  42. # Final geometry: MultiPolygon
  43. self.solid_geometry = None
  44. # Attributes to be included in serialization
  45. self.ser_attrs = ['units', 'solid_geometry']
  46. def union(self):
  47. """
  48. Runs a cascaded union on the list of objects in
  49. solid_geometry.
  50. :return: None
  51. """
  52. self.solid_geometry = [cascaded_union(self.solid_geometry)]
  53. def add_circle(self, origin, radius):
  54. """
  55. Adds a circle to the object.
  56. :param origin: Center of the circle.
  57. :param radius: Radius of the circle.
  58. :return: None
  59. """
  60. # TODO: Decide what solid_geometry is supposed to be and how we append to it.
  61. if self.solid_geometry is None:
  62. self.solid_geometry = []
  63. if type(self.solid_geometry) is list:
  64. self.solid_geometry.append(Point(origin).buffer(radius))
  65. return
  66. try:
  67. self.solid_geometry = self.solid_geometry.union(Point(origin).buffer(radius))
  68. except:
  69. print "Failed to run union on polygons."
  70. raise
  71. def add_polygon(self, points):
  72. """
  73. Adds a polygon to the object (by union)
  74. :param points: The vertices of the polygon.
  75. :return: None
  76. """
  77. if self.solid_geometry is None:
  78. self.solid_geometry = []
  79. if type(self.solid_geometry) is list:
  80. self.solid_geometry.append(Polygon(points))
  81. return
  82. try:
  83. self.solid_geometry = self.solid_geometry.union(Polygon(points))
  84. except:
  85. print "Failed to run union on polygons."
  86. raise
  87. def isolation_geometry(self, offset):
  88. """
  89. Creates contours around geometry at a given
  90. offset distance.
  91. :param offset: Offset distance.
  92. :type offset: float
  93. :return: The buffered geometry.
  94. :rtype: Shapely.MultiPolygon or Shapely.Polygon
  95. """
  96. return self.solid_geometry.buffer(offset)
  97. def bounds(self):
  98. """
  99. Returns coordinates of rectangular bounds
  100. of geometry: (xmin, ymin, xmax, ymax).
  101. """
  102. log.debug("Geometry->bounds()")
  103. if self.solid_geometry is None:
  104. log.debug("solid_geometry is None")
  105. log.warning("solid_geometry not computed yet.")
  106. return (0, 0, 0, 0)
  107. if type(self.solid_geometry) is list:
  108. log.debug("type(solid_geometry) is list")
  109. # TODO: This can be done faster. See comment from Shapely mailing lists.
  110. if len(self.solid_geometry) == 0:
  111. log.debug('solid_geometry is empty []')
  112. return (0, 0, 0, 0)
  113. log.debug('solid_geometry is not empty, returning cascaded union of items')
  114. return cascaded_union(self.solid_geometry).bounds
  115. else:
  116. log.debug("type(solid_geometry) is not list, returning .bounds property")
  117. return self.solid_geometry.bounds
  118. def size(self):
  119. """
  120. Returns (width, height) of rectangular
  121. bounds of geometry.
  122. """
  123. if self.solid_geometry is None:
  124. log.warning("Solid_geometry not computed yet.")
  125. return 0
  126. bounds = self.bounds()
  127. return (bounds[2]-bounds[0], bounds[3]-bounds[1])
  128. def get_empty_area(self, boundary=None):
  129. """
  130. Returns the complement of self.solid_geometry within
  131. the given boundary polygon. If not specified, it defaults to
  132. the rectangular bounding box of self.solid_geometry.
  133. """
  134. if boundary is None:
  135. boundary = self.solid_geometry.envelope
  136. return boundary.difference(self.solid_geometry)
  137. def clear_polygon(self, polygon, tooldia, overlap=0.15):
  138. """
  139. Creates geometry inside a polygon for a tool to cover
  140. the whole area.
  141. """
  142. poly_cuts = [polygon.buffer(-tooldia/2.0)]
  143. while True:
  144. polygon = poly_cuts[-1].buffer(-tooldia*(1-overlap))
  145. if polygon.area > 0:
  146. poly_cuts.append(polygon)
  147. else:
  148. break
  149. return poly_cuts
  150. def scale(self, factor):
  151. """
  152. Scales all of the object's geometry by a given factor. Override
  153. this method.
  154. :param factor: Number by which to scale.
  155. :type factor: float
  156. :return: None
  157. :rtype: None
  158. """
  159. return
  160. def offset(self, vect):
  161. """
  162. Offset the geometry by the given vector. Override this method.
  163. :param vect: (x, y) vector by which to offset the object.
  164. :type vect: tuple
  165. :return: None
  166. """
  167. return
  168. def convert_units(self, units):
  169. """
  170. Converts the units of the object to ``units`` by scaling all
  171. the geometry appropriately. This call ``scale()``. Don't call
  172. it again in descendents.
  173. :param units: "IN" or "MM"
  174. :type units: str
  175. :return: Scaling factor resulting from unit change.
  176. :rtype: float
  177. """
  178. log.debug("Geometry.convert_units()")
  179. if units.upper() == self.units.upper():
  180. return 1.0
  181. if units.upper() == "MM":
  182. factor = 25.4
  183. elif units.upper() == "IN":
  184. factor = 1/25.4
  185. else:
  186. log.error("Unsupported units: %s" % str(units))
  187. return 1.0
  188. self.units = units
  189. self.scale(factor)
  190. return factor
  191. def to_dict(self):
  192. """
  193. Returns a respresentation of the object as a dictionary.
  194. Attributes to include are listed in ``self.ser_attrs``.
  195. :return: A dictionary-encoded copy of the object.
  196. :rtype: dict
  197. """
  198. d = {}
  199. for attr in self.ser_attrs:
  200. d[attr] = getattr(self, attr)
  201. return d
  202. def from_dict(self, d):
  203. """
  204. Sets object's attributes from a dictionary.
  205. Attributes to include are listed in ``self.ser_attrs``.
  206. This method will look only for only and all the
  207. attributes in ``self.ser_attrs``. They must all
  208. be present. Use only for deserializing saved
  209. objects.
  210. :param d: Dictionary of attributes to set in the object.
  211. :type d: dict
  212. :return: None
  213. """
  214. for attr in self.ser_attrs:
  215. setattr(self, attr, d[attr])
  216. class ApertureMacro:
  217. """
  218. Syntax of aperture macros.
  219. <AM command>: AM<Aperture macro name>*<Macro content>
  220. <Macro content>: {{<Variable definition>*}{<Primitive>*}}
  221. <Variable definition>: $K=<Arithmetic expression>
  222. <Primitive>: <Primitive code>,<Modifier>{,<Modifier>}|<Comment>
  223. <Modifier>: $M|< Arithmetic expression>
  224. <Comment>: 0 <Text>
  225. """
  226. ## Regular expressions
  227. am1_re = re.compile(r'^%AM([^\*]+)\*(.+)?(%)?$')
  228. am2_re = re.compile(r'(.*)%$')
  229. amcomm_re = re.compile(r'^0(.*)')
  230. amprim_re = re.compile(r'^[1-9].*')
  231. amvar_re = re.compile(r'^\$([0-9a-zA-z]+)=(.*)')
  232. def __init__(self, name=None):
  233. self.name = name
  234. self.raw = ""
  235. ## These below are recomputed for every aperture
  236. ## definition, in other words, are temporary variables.
  237. self.primitives = []
  238. self.locvars = {}
  239. self.geometry = None
  240. def to_dict(self):
  241. """
  242. Returns the object in a serializable form. Only the name and
  243. raw are required.
  244. :return: Dictionary representing the object. JSON ready.
  245. :rtype: dict
  246. """
  247. return {
  248. 'name': self.name,
  249. 'raw': self.raw
  250. }
  251. def from_dict(self, d):
  252. """
  253. Populates the object from a serial representation created
  254. with ``self.to_dict()``.
  255. :param d: Serial representation of an ApertureMacro object.
  256. :return: None
  257. """
  258. for attr in ['name', 'raw']:
  259. setattr(self, attr, d[attr])
  260. def parse_content(self):
  261. """
  262. Creates numerical lists for all primitives in the aperture
  263. macro (in ``self.raw``) by replacing all variables by their
  264. values iteratively and evaluating expressions. Results
  265. are stored in ``self.primitives``.
  266. :return: None
  267. """
  268. # Cleanup
  269. self.raw = self.raw.replace('\n', '').replace('\r', '').strip(" *")
  270. self.primitives = []
  271. # Separate parts
  272. parts = self.raw.split('*')
  273. #### Every part in the macro ####
  274. for part in parts:
  275. ### Comments. Ignored.
  276. match = ApertureMacro.amcomm_re.search(part)
  277. if match:
  278. continue
  279. ### Variables
  280. # These are variables defined locally inside the macro. They can be
  281. # numerical constant or defind in terms of previously define
  282. # variables, which can be defined locally or in an aperture
  283. # definition. All replacements ocurr here.
  284. match = ApertureMacro.amvar_re.search(part)
  285. if match:
  286. var = match.group(1)
  287. val = match.group(2)
  288. # Replace variables in value
  289. for v in self.locvars:
  290. val = re.sub(r'\$'+str(v)+r'(?![0-9a-zA-Z])', str(self.locvars[v]), val)
  291. # Make all others 0
  292. val = re.sub(r'\$[0-9a-zA-Z](?![0-9a-zA-Z])', "0", val)
  293. # Change x with *
  294. val = re.sub(r'[xX]', "*", val)
  295. # Eval() and store.
  296. self.locvars[var] = eval(val)
  297. continue
  298. ### Primitives
  299. # Each is an array. The first identifies the primitive, while the
  300. # rest depend on the primitive. All are strings representing a
  301. # number and may contain variable definition. The values of these
  302. # variables are defined in an aperture definition.
  303. match = ApertureMacro.amprim_re.search(part)
  304. if match:
  305. ## Replace all variables
  306. for v in self.locvars:
  307. part = re.sub(r'\$'+str(v)+r'(?![0-9a-zA-Z])', str(self.locvars[v]), part)
  308. # Make all others 0
  309. part = re.sub(r'\$[0-9a-zA-Z](?![0-9a-zA-Z])', "0", part)
  310. # Change x with *
  311. part = re.sub(r'[xX]', "*", part)
  312. ## Store
  313. elements = part.split(",")
  314. self.primitives.append([eval(x) for x in elements])
  315. continue
  316. log.warning("Unknown syntax of aperture macro part: %s" % str(part))
  317. def append(self, data):
  318. """
  319. Appends a string to the raw macro.
  320. :param data: Part of the macro.
  321. :type data: str
  322. :return: None
  323. """
  324. self.raw += data
  325. @staticmethod
  326. def default2zero(n, mods):
  327. """
  328. Pads the ``mods`` list with zeros resulting in an
  329. list of length n.
  330. :param n: Length of the resulting list.
  331. :type n: int
  332. :param mods: List to be padded.
  333. :type mods: list
  334. :return: Zero-padded list.
  335. :rtype: list
  336. """
  337. x = [0.0]*n
  338. na = len(mods)
  339. x[0:na] = mods
  340. return x
  341. @staticmethod
  342. def make_circle(mods):
  343. """
  344. :param mods: (Exposure 0/1, Diameter >=0, X-coord, Y-coord)
  345. :return:
  346. """
  347. pol, dia, x, y = ApertureMacro.default2zero(4, mods)
  348. return {"pol": int(pol), "geometry": Point(x, y).buffer(dia/2)}
  349. @staticmethod
  350. def make_vectorline(mods):
  351. """
  352. :param mods: (Exposure 0/1, Line width >= 0, X-start, Y-start, X-end, Y-end,
  353. rotation angle around origin in degrees)
  354. :return:
  355. """
  356. pol, width, xs, ys, xe, ye, angle = ApertureMacro.default2zero(7, mods)
  357. line = LineString([(xs, ys), (xe, ye)])
  358. box = line.buffer(width/2, cap_style=2)
  359. box_rotated = affinity.rotate(box, angle, origin=(0, 0))
  360. return {"pol": int(pol), "geometry": box_rotated}
  361. @staticmethod
  362. def make_centerline(mods):
  363. """
  364. :param mods: (Exposure 0/1, width >=0, height >=0, x-center, y-center,
  365. rotation angle around origin in degrees)
  366. :return:
  367. """
  368. pol, width, height, x, y, angle = ApertureMacro.default2zero(6, mods)
  369. box = shply_box(x-width/2, y-height/2, x+width/2, y+height/2)
  370. box_rotated = affinity.rotate(box, angle, origin=(0, 0))
  371. return {"pol": int(pol), "geometry": box_rotated}
  372. @staticmethod
  373. def make_lowerleftline(mods):
  374. """
  375. :param mods: (exposure 0/1, width >=0, height >=0, x-lowerleft, y-lowerleft,
  376. rotation angle around origin in degrees)
  377. :return:
  378. """
  379. pol, width, height, x, y, angle = ApertureMacro.default2zero(6, mods)
  380. box = shply_box(x, y, x+width, y+height)
  381. box_rotated = affinity.rotate(box, angle, origin=(0, 0))
  382. return {"pol": int(pol), "geometry": box_rotated}
  383. @staticmethod
  384. def make_outline(mods):
  385. """
  386. :param mods:
  387. :return:
  388. """
  389. pol = mods[0]
  390. n = mods[1]
  391. points = [(0, 0)]*(n+1)
  392. for i in range(n+1):
  393. points[i] = mods[2*i + 2:2*i + 4]
  394. angle = mods[2*n + 4]
  395. poly = Polygon(points)
  396. poly_rotated = affinity.rotate(poly, angle, origin=(0, 0))
  397. return {"pol": int(pol), "geometry": poly_rotated}
  398. @staticmethod
  399. def make_polygon(mods):
  400. """
  401. Note: Specs indicate that rotation is only allowed if the center
  402. (x, y) == (0, 0). I will tolerate breaking this rule.
  403. :param mods: (exposure 0/1, n_verts 3<=n<=12, x-center, y-center,
  404. diameter of circumscribed circle >=0, rotation angle around origin)
  405. :return:
  406. """
  407. pol, nverts, x, y, dia, angle = ApertureMacro.default2zero(6, mods)
  408. points = [(0, 0)]*nverts
  409. for i in range(nverts):
  410. points[i] = (x + 0.5 * dia * cos(2*pi * i/nverts),
  411. y + 0.5 * dia * sin(2*pi * i/nverts))
  412. poly = Polygon(points)
  413. poly_rotated = affinity.rotate(poly, angle, origin=(0, 0))
  414. return {"pol": int(pol), "geometry": poly_rotated}
  415. @staticmethod
  416. def make_moire(mods):
  417. """
  418. Note: Specs indicate that rotation is only allowed if the center
  419. (x, y) == (0, 0). I will tolerate breaking this rule.
  420. :param mods: (x-center, y-center, outer_dia_outer_ring, ring thickness,
  421. gap, max_rings, crosshair_thickness, crosshair_len, rotation
  422. angle around origin in degrees)
  423. :return:
  424. """
  425. x, y, dia, thickness, gap, nrings, cross_th, cross_len, angle = ApertureMacro.default2zero(9, mods)
  426. r = dia/2 - thickness/2
  427. result = Point((x, y)).buffer(r).exterior.buffer(thickness/2.0)
  428. ring = Point((x, y)).buffer(r).exterior.buffer(thickness/2.0) # Need a copy!
  429. i = 1 # Number of rings created so far
  430. ## If the ring does not have an interior it means that it is
  431. ## a disk. Then stop.
  432. while len(ring.interiors) > 0 and i < nrings:
  433. r -= thickness + gap
  434. if r <= 0:
  435. break
  436. ring = Point((x, y)).buffer(r).exterior.buffer(thickness/2.0)
  437. result = cascaded_union([result, ring])
  438. i += 1
  439. ## Crosshair
  440. hor = LineString([(x - cross_len, y), (x + cross_len, y)]).buffer(cross_th/2.0, cap_style=2)
  441. ver = LineString([(x, y-cross_len), (x, y + cross_len)]).buffer(cross_th/2.0, cap_style=2)
  442. result = cascaded_union([result, hor, ver])
  443. return {"pol": 1, "geometry": result}
  444. @staticmethod
  445. def make_thermal(mods):
  446. """
  447. Note: Specs indicate that rotation is only allowed if the center
  448. (x, y) == (0, 0). I will tolerate breaking this rule.
  449. :param mods: [x-center, y-center, diameter-outside, diameter-inside,
  450. gap-thickness, rotation angle around origin]
  451. :return:
  452. """
  453. x, y, dout, din, t, angle = ApertureMacro.default2zero(6, mods)
  454. ring = Point((x, y)).buffer(dout/2.0).difference(Point((x, y)).buffer(din/2.0))
  455. hline = LineString([(x - dout/2.0, y), (x + dout/2.0, y)]).buffer(t/2.0, cap_style=3)
  456. vline = LineString([(x, y - dout/2.0), (x, y + dout/2.0)]).buffer(t/2.0, cap_style=3)
  457. thermal = ring.difference(hline.union(vline))
  458. return {"pol": 1, "geometry": thermal}
  459. def make_geometry(self, modifiers):
  460. """
  461. Runs the macro for the given modifiers and generates
  462. the corresponding geometry.
  463. :param modifiers: Modifiers (parameters) for this macro
  464. :type modifiers: list
  465. """
  466. ## Primitive makers
  467. makers = {
  468. "1": ApertureMacro.make_circle,
  469. "2": ApertureMacro.make_vectorline,
  470. "20": ApertureMacro.make_vectorline,
  471. "21": ApertureMacro.make_centerline,
  472. "22": ApertureMacro.make_lowerleftline,
  473. "4": ApertureMacro.make_outline,
  474. "5": ApertureMacro.make_polygon,
  475. "6": ApertureMacro.make_moire,
  476. "7": ApertureMacro.make_thermal
  477. }
  478. ## Store modifiers as local variables
  479. modifiers = modifiers or []
  480. modifiers = [float(m) for m in modifiers]
  481. self.locvars = {}
  482. for i in range(0, len(modifiers)):
  483. self.locvars[str(i+1)] = modifiers[i]
  484. ## Parse
  485. self.primitives = [] # Cleanup
  486. self.geometry = None
  487. self.parse_content()
  488. ## Make the geometry
  489. for primitive in self.primitives:
  490. # Make the primitive
  491. prim_geo = makers[str(int(primitive[0]))](primitive[1:])
  492. # Add it (according to polarity)
  493. if self.geometry is None and prim_geo['pol'] == 1:
  494. self.geometry = prim_geo['geometry']
  495. continue
  496. if prim_geo['pol'] == 1:
  497. self.geometry = self.geometry.union(prim_geo['geometry'])
  498. continue
  499. if prim_geo['pol'] == 0:
  500. self.geometry = self.geometry.difference(prim_geo['geometry'])
  501. continue
  502. return self.geometry
  503. class Gerber (Geometry):
  504. """
  505. **ATTRIBUTES**
  506. * ``apertures`` (dict): The keys are names/identifiers of each aperture.
  507. The values are dictionaries key/value pairs which describe the aperture. The
  508. type key is always present and the rest depend on the key:
  509. +-----------+-----------------------------------+
  510. | Key | Value |
  511. +===========+===================================+
  512. | type | (str) "C", "R", "O", "P", or "AP" |
  513. +-----------+-----------------------------------+
  514. | others | Depend on ``type`` |
  515. +-----------+-----------------------------------+
  516. * ``aperture_macros`` (dictionary): Are predefined geometrical structures
  517. that can be instanciated with different parameters in an aperture
  518. definition. See ``apertures`` above. The key is the name of the macro,
  519. and the macro itself, the value, is a ``Aperture_Macro`` object.
  520. * ``flash_geometry`` (list): List of (Shapely) geometric object resulting
  521. from ``flashes``. These are generated from ``flashes`` in ``do_flashes()``.
  522. * ``buffered_paths`` (list): List of (Shapely) polygons resulting from
  523. *buffering* (or thickening) the ``paths`` with the aperture. These are
  524. generated from ``paths`` in ``buffer_paths()``.
  525. **USAGE**::
  526. g = Gerber()
  527. g.parse_file(filename)
  528. g.create_geometry()
  529. do_something(s.solid_geometry)
  530. """
  531. def __init__(self):
  532. """
  533. The constructor takes no parameters. Use ``gerber.parse_files()``
  534. or ``gerber.parse_lines()`` to populate the object from Gerber source.
  535. :return: Gerber object
  536. :rtype: Gerber
  537. """
  538. # Initialize parent
  539. Geometry.__init__(self)
  540. self.solid_geometry = Polygon()
  541. # Number format
  542. self.int_digits = 3
  543. """Number of integer digits in Gerber numbers. Used during parsing."""
  544. self.frac_digits = 4
  545. """Number of fraction digits in Gerber numbers. Used during parsing."""
  546. ## Gerber elements ##
  547. # Apertures {'id':{'type':chr,
  548. # ['size':float], ['width':float],
  549. # ['height':float]}, ...}
  550. self.apertures = {}
  551. # Aperture Macros
  552. self.aperture_macros = {}
  553. # Attributes to be included in serialization
  554. # Always append to it because it carries contents
  555. # from Geometry.
  556. self.ser_attrs += ['int_digits', 'frac_digits', 'apertures',
  557. 'aperture_macros', 'solid_geometry']
  558. #### Parser patterns ####
  559. # FS - Format Specification
  560. # The format of X and Y must be the same!
  561. # L-omit leading zeros, T-omit trailing zeros
  562. # A-absolute notation, I-incremental notation
  563. self.fmt_re = re.compile(r'%FS([LT])([AI])X(\d)(\d)Y\d\d\*%$')
  564. # Mode (IN/MM)
  565. self.mode_re = re.compile(r'^%MO(IN|MM)\*%$')
  566. # Comment G04|G4
  567. self.comm_re = re.compile(r'^G0?4(.*)$')
  568. # AD - Aperture definition
  569. self.ad_re = re.compile(r'^%ADD(\d\d+)([a-zA-Z_$\.][a-zA-Z0-9_$\.]*)(?:,(.*))?\*%$')
  570. # AM - Aperture Macro
  571. # Beginning of macro (Ends with *%):
  572. #self.am_re = re.compile(r'^%AM([a-zA-Z0-9]*)\*')
  573. # Tool change
  574. # May begin with G54 but that is deprecated
  575. self.tool_re = re.compile(r'^(?:G54)?D(\d\d+)\*$')
  576. # G01... - Linear interpolation plus flashes with coordinates
  577. # Operation code (D0x) missing is deprecated... oh well I will support it.
  578. self.lin_re = re.compile(r'^(?:G0?(1))?(?=.*X(-?\d+))?(?=.*Y(-?\d+))?[XY][^DIJ]*(?:D0?([123]))?\*$')
  579. # Operation code alone, usually just D03 (Flash)
  580. self.opcode_re = re.compile(r'^D0?([123])\*$')
  581. # G02/3... - Circular interpolation with coordinates
  582. # 2-clockwise, 3-counterclockwise
  583. # Operation code (D0x) missing is deprecated... oh well I will support it.
  584. # Optional start with G02 or G03, optional end with D01 or D02 with
  585. # optional coordinates but at least one in any order.
  586. self.circ_re = re.compile(r'^(?:G0?([23]))?(?=.*X(-?\d+))?(?=.*Y(-?\d+))' +
  587. '?(?=.*I(-?\d+))?(?=.*J(-?\d+))?[XYIJ][^D]*(?:D0([12]))?\*$')
  588. # G01/2/3 Occurring without coordinates
  589. self.interp_re = re.compile(r'^(?:G0?([123]))\*')
  590. # Single D74 or multi D75 quadrant for circular interpolation
  591. self.quad_re = re.compile(r'^G7([45])\*$')
  592. # Region mode on
  593. # In region mode, D01 starts a region
  594. # and D02 ends it. A new region can be started again
  595. # with D01. All contours must be closed before
  596. # D02 or G37.
  597. self.regionon_re = re.compile(r'^G36\*$')
  598. # Region mode off
  599. # Will end a region and come off region mode.
  600. # All contours must be closed before D02 or G37.
  601. self.regionoff_re = re.compile(r'^G37\*$')
  602. # End of file
  603. self.eof_re = re.compile(r'^M02\*')
  604. # IP - Image polarity
  605. self.pol_re = re.compile(r'^%IP(POS|NEG)\*%$')
  606. # LP - Level polarity
  607. self.lpol_re = re.compile(r'^%LP([DC])\*%$')
  608. # Units (OBSOLETE)
  609. self.units_re = re.compile(r'^G7([01])\*$')
  610. # Absolute/Relative G90/1 (OBSOLETE)
  611. self.absrel_re = re.compile(r'^G9([01])\*$')
  612. # Aperture macros
  613. self.am1_re = re.compile(r'^%AM([^\*]+)\*([^%]+)?(%)?$')
  614. self.am2_re = re.compile(r'(.*)%$')
  615. # TODO: This is bad.
  616. self.steps_per_circ = 40
  617. def scale(self, factor):
  618. """
  619. Scales the objects' geometry on the XY plane by a given factor.
  620. These are:
  621. * ``buffered_paths``
  622. * ``flash_geometry``
  623. * ``solid_geometry``
  624. * ``regions``
  625. NOTE:
  626. Does not modify the data used to create these elements. If these
  627. are recreated, the scaling will be lost. This behavior was modified
  628. because of the complexity reached in this class.
  629. :param factor: Number by which to scale.
  630. :type factor: float
  631. :rtype : None
  632. """
  633. ## solid_geometry ???
  634. # It's a cascaded union of objects.
  635. self.solid_geometry = affinity.scale(self.solid_geometry, factor,
  636. factor, origin=(0, 0))
  637. # # Now buffered_paths, flash_geometry and solid_geometry
  638. # self.create_geometry()
  639. def offset(self, vect):
  640. """
  641. Offsets the objects' geometry on the XY plane by a given vector.
  642. These are:
  643. * ``buffered_paths``
  644. * ``flash_geometry``
  645. * ``solid_geometry``
  646. * ``regions``
  647. NOTE:
  648. Does not modify the data used to create these elements. If these
  649. are recreated, the scaling will be lost. This behavior was modified
  650. because of the complexity reached in this class.
  651. :param vect: (x, y) offset vector.
  652. :type vect: tuple
  653. :return: None
  654. """
  655. dx, dy = vect
  656. ## Solid geometry
  657. self.solid_geometry = affinity.translate(self.solid_geometry, xoff=dx, yoff=dy)
  658. def mirror(self, axis, point):
  659. """
  660. Mirrors the object around a specified axis passign through
  661. the given point. What is affected:
  662. * ``buffered_paths``
  663. * ``flash_geometry``
  664. * ``solid_geometry``
  665. * ``regions``
  666. NOTE:
  667. Does not modify the data used to create these elements. If these
  668. are recreated, the scaling will be lost. This behavior was modified
  669. because of the complexity reached in this class.
  670. :param axis: "X" or "Y" indicates around which axis to mirror.
  671. :type axis: str
  672. :param point: [x, y] point belonging to the mirror axis.
  673. :type point: list
  674. :return: None
  675. """
  676. px, py = point
  677. xscale, yscale = {"X": (1.0, -1.0), "Y": (-1.0, 1.0)}[axis]
  678. ## solid_geometry ???
  679. # It's a cascaded union of objects.
  680. self.solid_geometry = affinity.scale(self.solid_geometry,
  681. xscale, yscale, origin=(px, py))
  682. def aperture_parse(self, apertureId, apertureType, apParameters):
  683. """
  684. Parse gerber aperture definition into dictionary of apertures.
  685. The following kinds and their attributes are supported:
  686. * *Circular (C)*: size (float)
  687. * *Rectangle (R)*: width (float), height (float)
  688. * *Obround (O)*: width (float), height (float).
  689. * *Polygon (P)*: diameter(float), vertices(int), [rotation(float)]
  690. * *Aperture Macro (AM)*: macro (ApertureMacro), modifiers (list)
  691. :param apertureId: Id of the aperture being defined.
  692. :param apertureType: Type of the aperture.
  693. :param apParameters: Parameters of the aperture.
  694. :type apertureId: str
  695. :type apertureType: str
  696. :type apParameters: str
  697. :return: Identifier of the aperture.
  698. :rtype: str
  699. """
  700. # Found some Gerber with a leading zero in the aperture id and the
  701. # referenced it without the zero, so this is a hack to handle that.
  702. apid = str(int(apertureId))
  703. try: # Could be empty for aperture macros
  704. paramList = apParameters.split('X')
  705. except:
  706. paramList = None
  707. if apertureType == "C": # Circle, example: %ADD11C,0.1*%
  708. self.apertures[apid] = {"type": "C",
  709. "size": float(paramList[0])}
  710. return apid
  711. if apertureType == "R": # Rectangle, example: %ADD15R,0.05X0.12*%
  712. self.apertures[apid] = {"type": "R",
  713. "width": float(paramList[0]),
  714. "height": float(paramList[1]),
  715. "size": sqrt(float(paramList[0])**2 + float(paramList[1])**2)} # Hack
  716. return apid
  717. if apertureType == "O": # Obround
  718. self.apertures[apid] = {"type": "O",
  719. "width": float(paramList[0]),
  720. "height": float(paramList[1]),
  721. "size": sqrt(float(paramList[0])**2 + float(paramList[1])**2)} # Hack
  722. return apid
  723. if apertureType == "P": # Polygon (regular)
  724. self.apertures[apid] = {"type": "P",
  725. "diam": float(paramList[0]),
  726. "nVertices": int(paramList[1]),
  727. "size": float(paramList[0])} # Hack
  728. if len(paramList) >= 3:
  729. self.apertures[apid]["rotation"] = float(paramList[2])
  730. return apid
  731. if apertureType in self.aperture_macros:
  732. self.apertures[apid] = {"type": "AM",
  733. "macro": self.aperture_macros[apertureType],
  734. "modifiers": paramList}
  735. return apid
  736. log.warning("Aperture not implemented: %s" % str(apertureType))
  737. return None
  738. def parse_file(self, filename, follow=False):
  739. """
  740. Calls Gerber.parse_lines() with array of lines
  741. read from the given file.
  742. :param filename: Gerber file to parse.
  743. :type filename: str
  744. :param follow: If true, will not create polygons, just lines
  745. following the gerber path.
  746. :type follow: bool
  747. :return: None
  748. """
  749. gfile = open(filename, 'r')
  750. gstr = gfile.readlines()
  751. gfile.close()
  752. self.parse_lines(gstr, follow=follow)
  753. def parse_lines(self, glines, follow=False):
  754. """
  755. Main Gerber parser. Reads Gerber and populates ``self.paths``, ``self.apertures``,
  756. ``self.flashes``, ``self.regions`` and ``self.units``.
  757. :param glines: Gerber code as list of strings, each element being
  758. one line of the source file.
  759. :type glines: list
  760. :param follow: If true, will not create polygons, just lines
  761. following the gerber path.
  762. :type follow: bool
  763. :return: None
  764. :rtype: None
  765. """
  766. # Coordinates of the current path, each is [x, y]
  767. path = []
  768. # Polygons are stored here until there is a change in polarity.
  769. # Only then they are combined via cascaded_union and added or
  770. # subtracted from solid_geometry. This is ~100 times faster than
  771. # applyng a union for every new polygon.
  772. poly_buffer = []
  773. last_path_aperture = None
  774. current_aperture = None
  775. # 1,2 or 3 from "G01", "G02" or "G03"
  776. current_interpolation_mode = None
  777. # 1 or 2 from "D01" or "D02"
  778. # Note this is to support deprecated Gerber not putting
  779. # an operation code at the end of every coordinate line.
  780. current_operation_code = None
  781. # Current coordinates
  782. current_x = None
  783. current_y = None
  784. # Absolute or Relative/Incremental coordinates
  785. # Not implemented
  786. absolute = True
  787. # How to interpret circular interpolation: SINGLE or MULTI
  788. quadrant_mode = None
  789. # Indicates we are parsing an aperture macro
  790. current_macro = None
  791. # Indicates the current polarity: D-Dark, C-Clear
  792. current_polarity = 'D'
  793. # If a region is being defined
  794. making_region = False
  795. #### Parsing starts here ####
  796. line_num = 0
  797. gline = ""
  798. try:
  799. for gline in glines:
  800. line_num += 1
  801. ### Cleanup
  802. gline = gline.strip(' \r\n')
  803. ### Aperture Macros
  804. # Having this at the beggining will slow things down
  805. # but macros can have complicated statements than could
  806. # be caught by other patterns.
  807. if current_macro is None: # No macro started yet
  808. match = self.am1_re.search(gline)
  809. # Start macro if match, else not an AM, carry on.
  810. if match:
  811. log.info("Starting macro. Line %d: %s" % (line_num, gline))
  812. current_macro = match.group(1)
  813. self.aperture_macros[current_macro] = ApertureMacro(name=current_macro)
  814. if match.group(2): # Append
  815. self.aperture_macros[current_macro].append(match.group(2))
  816. if match.group(3): # Finish macro
  817. #self.aperture_macros[current_macro].parse_content()
  818. current_macro = None
  819. log.info("Macro complete in 1 line.")
  820. continue
  821. else: # Continue macro
  822. log.info("Continuing macro. Line %d." % line_num)
  823. match = self.am2_re.search(gline)
  824. if match: # Finish macro
  825. log.info("End of macro. Line %d." % line_num)
  826. self.aperture_macros[current_macro].append(match.group(1))
  827. #self.aperture_macros[current_macro].parse_content()
  828. current_macro = None
  829. else: # Append
  830. self.aperture_macros[current_macro].append(gline)
  831. continue
  832. ### G01 - Linear interpolation plus flashes
  833. # Operation code (D0x) missing is deprecated... oh well I will support it.
  834. # REGEX: r'^(?:G0?(1))?(?:X(-?\d+))?(?:Y(-?\d+))?(?:D0([123]))?\*$'
  835. match = self.lin_re.search(gline)
  836. if match:
  837. # Dxx alone?
  838. # if match.group(1) is None and match.group(2) is None and match.group(3) is None:
  839. # try:
  840. # current_operation_code = int(match.group(4))
  841. # except:
  842. # pass # A line with just * will match too.
  843. # continue
  844. # NOTE: Letting it continue allows it to react to the
  845. # operation code.
  846. # Parse coordinates
  847. if match.group(2) is not None:
  848. current_x = parse_gerber_number(match.group(2), self.frac_digits)
  849. if match.group(3) is not None:
  850. current_y = parse_gerber_number(match.group(3), self.frac_digits)
  851. # Parse operation code
  852. if match.group(4) is not None:
  853. current_operation_code = int(match.group(4))
  854. # Pen down: add segment
  855. if current_operation_code == 1:
  856. path.append([current_x, current_y])
  857. last_path_aperture = current_aperture
  858. elif current_operation_code == 2:
  859. if len(path) > 1:
  860. ## --- BUFFERED ---
  861. if making_region:
  862. geo = Polygon(path)
  863. else:
  864. if last_path_aperture is None:
  865. log.warning("No aperture defined for curent path. (%d)" % line_num)
  866. width = self.apertures[last_path_aperture]["size"] # TODO: WARNING this should fail!
  867. #log.debug("Line %d: Setting aperture to %s before buffering." % (line_num, last_path_aperture))
  868. if follow:
  869. geo = LineString(path)
  870. else:
  871. geo = LineString(path).buffer(width/2)
  872. poly_buffer.append(geo)
  873. path = [[current_x, current_y]] # Start new path
  874. # Flash
  875. elif current_operation_code == 3:
  876. # --- BUFFERED ---
  877. flash = Gerber.create_flash_geometry(Point([current_x, current_y]),
  878. self.apertures[current_aperture])
  879. poly_buffer.append(flash)
  880. continue
  881. ### G02/3 - Circular interpolation
  882. # 2-clockwise, 3-counterclockwise
  883. match = self.circ_re.search(gline)
  884. if match:
  885. mode, x, y, i, j, d = match.groups()
  886. try:
  887. x = parse_gerber_number(x, self.frac_digits)
  888. except:
  889. x = current_x
  890. try:
  891. y = parse_gerber_number(y, self.frac_digits)
  892. except:
  893. y = current_y
  894. try:
  895. i = parse_gerber_number(i, self.frac_digits)
  896. except:
  897. i = 0
  898. try:
  899. j = parse_gerber_number(j, self.frac_digits)
  900. except:
  901. j = 0
  902. if quadrant_mode is None:
  903. log.error("Found arc without preceding quadrant specification G74 or G75. (%d)" % line_num)
  904. log.error(gline)
  905. continue
  906. if mode is None and current_interpolation_mode not in [2, 3]:
  907. log.error("Found arc without circular interpolation mode defined. (%d)" % line_num)
  908. log.error(gline)
  909. continue
  910. elif mode is not None:
  911. current_interpolation_mode = int(mode)
  912. # Set operation code if provided
  913. if d is not None:
  914. current_operation_code = int(d)
  915. # Nothing created! Pen Up.
  916. if current_operation_code == 2:
  917. log.warning("Arc with D2. (%d)" % line_num)
  918. if len(path) > 1:
  919. if last_path_aperture is None:
  920. log.warning("No aperture defined for curent path. (%d)" % line_num)
  921. # --- BUFFERED ---
  922. width = self.apertures[last_path_aperture]["size"]
  923. buffered = LineString(path).buffer(width/2)
  924. poly_buffer.append(buffered)
  925. current_x = x
  926. current_y = y
  927. path = [[current_x, current_y]] # Start new path
  928. continue
  929. # Flash should not happen here
  930. if current_operation_code == 3:
  931. log.error("Trying to flash within arc. (%d)" % line_num)
  932. continue
  933. if quadrant_mode == 'MULTI':
  934. center = [i + current_x, j + current_y]
  935. radius = sqrt(i**2 + j**2)
  936. start = arctan2(-j, -i)
  937. stop = arctan2(-center[1] + y, -center[0] + x)
  938. arcdir = [None, None, "cw", "ccw"]
  939. this_arc = arc(center, radius, start, stop,
  940. arcdir[current_interpolation_mode],
  941. self.steps_per_circ)
  942. # Last point in path is current point
  943. current_x = this_arc[-1][0]
  944. current_y = this_arc[-1][1]
  945. # Append
  946. path += this_arc
  947. last_path_aperture = current_aperture
  948. continue
  949. if quadrant_mode == 'SINGLE':
  950. log.warning("Single quadrant arc are not implemented yet. (%d)" % line_num)
  951. ### Operation code alone
  952. # Operation code alone, usually just D03 (Flash)
  953. # self.opcode_re = re.compile(r'^D0?([123])\*$')
  954. match = self.opcode_re.search(gline)
  955. if match:
  956. current_operation_code = int(match.group(1))
  957. if current_operation_code == 3:
  958. ## --- Buffered ---
  959. try:
  960. flash = Gerber.create_flash_geometry(Point(path[-1]),
  961. self.apertures[current_aperture])
  962. poly_buffer.append(flash)
  963. except IndexError:
  964. log.warning("Line %d: %s -> Nothing there to flash!" % (line_num, gline))
  965. continue
  966. ### G74/75* - Single or multiple quadrant arcs
  967. match = self.quad_re.search(gline)
  968. if match:
  969. if match.group(1) == '4':
  970. quadrant_mode = 'SINGLE'
  971. else:
  972. quadrant_mode = 'MULTI'
  973. continue
  974. ### G36* - Begin region
  975. if self.regionon_re.search(gline):
  976. if len(path) > 1:
  977. # Take care of what is left in the path
  978. ## --- Buffered ---
  979. width = self.apertures[last_path_aperture]["size"]
  980. geo = LineString(path).buffer(width/2)
  981. poly_buffer.append(geo)
  982. path = [path[-1]]
  983. making_region = True
  984. continue
  985. ### G37* - End region
  986. if self.regionoff_re.search(gline):
  987. making_region = False
  988. # Only one path defines region?
  989. # This can happen if D02 happened before G37 and
  990. # is not and error.
  991. if len(path) < 3:
  992. # print "ERROR: Path contains less than 3 points:"
  993. # print path
  994. # print "Line (%d): " % line_num, gline
  995. # path = []
  996. #path = [[current_x, current_y]]
  997. continue
  998. # For regions we may ignore an aperture that is None
  999. # self.regions.append({"polygon": Polygon(path),
  1000. # "aperture": last_path_aperture})
  1001. # --- Buffered ---
  1002. region = Polygon(path)
  1003. if not region.is_valid:
  1004. region = region.buffer(0)
  1005. poly_buffer.append(region)
  1006. path = [[current_x, current_y]] # Start new path
  1007. continue
  1008. ### Aperture definitions %ADD...
  1009. match = self.ad_re.search(gline)
  1010. if match:
  1011. log.info("Found aperture definition. Line %d: %s" % (line_num, gline))
  1012. self.aperture_parse(match.group(1), match.group(2), match.group(3))
  1013. continue
  1014. ### G01/2/3* - Interpolation mode change
  1015. # Can occur along with coordinates and operation code but
  1016. # sometimes by itself (handled here).
  1017. # Example: G01*
  1018. match = self.interp_re.search(gline)
  1019. if match:
  1020. current_interpolation_mode = int(match.group(1))
  1021. continue
  1022. ### Tool/aperture change
  1023. # Example: D12*
  1024. match = self.tool_re.search(gline)
  1025. if match:
  1026. log.debug("Line %d: Aperture change to (%s)" % (line_num, match.group(1)))
  1027. current_aperture = match.group(1)
  1028. # Take care of the current path with the previous tool
  1029. if len(path) > 1:
  1030. # --- Buffered ----
  1031. width = self.apertures[last_path_aperture]["size"]
  1032. geo = LineString(path).buffer(width/2)
  1033. poly_buffer.append(geo)
  1034. path = [path[-1]]
  1035. continue
  1036. ### Polarity change
  1037. # Example: %LPD*% or %LPC*%
  1038. match = self.lpol_re.search(gline)
  1039. if match:
  1040. if len(path) > 1 and current_polarity != match.group(1):
  1041. # --- Buffered ----
  1042. width = self.apertures[last_path_aperture]["size"]
  1043. geo = LineString(path).buffer(width/2)
  1044. poly_buffer.append(geo)
  1045. path = [path[-1]]
  1046. # --- Apply buffer ---
  1047. if current_polarity == 'D':
  1048. self.solid_geometry = self.solid_geometry.union(cascaded_union(poly_buffer))
  1049. else:
  1050. self.solid_geometry = self.solid_geometry.difference(cascaded_union(poly_buffer))
  1051. poly_buffer = []
  1052. current_polarity = match.group(1)
  1053. continue
  1054. ### Number format
  1055. # Example: %FSLAX24Y24*%
  1056. # TODO: This is ignoring most of the format. Implement the rest.
  1057. match = self.fmt_re.search(gline)
  1058. if match:
  1059. absolute = {'A': True, 'I': False}
  1060. self.int_digits = int(match.group(3))
  1061. self.frac_digits = int(match.group(4))
  1062. continue
  1063. ### Mode (IN/MM)
  1064. # Example: %MOIN*%
  1065. match = self.mode_re.search(gline)
  1066. if match:
  1067. self.units = match.group(1)
  1068. continue
  1069. ### Units (G70/1) OBSOLETE
  1070. match = self.units_re.search(gline)
  1071. if match:
  1072. self.units = {'0': 'IN', '1': 'MM'}[match.group(1)]
  1073. continue
  1074. ### Absolute/relative coordinates G90/1 OBSOLETE
  1075. match = self.absrel_re.search(gline)
  1076. if match:
  1077. absolute = {'0': True, '1': False}[match.group(1)]
  1078. continue
  1079. #### Ignored lines
  1080. ## Comments
  1081. match = self.comm_re.search(gline)
  1082. if match:
  1083. continue
  1084. ## EOF
  1085. match = self.eof_re.search(gline)
  1086. if match:
  1087. continue
  1088. ### Line did not match any pattern. Warn user.
  1089. log.warning("Line ignored (%d): %s" % (line_num, gline))
  1090. if len(path) > 1:
  1091. # EOF, create shapely LineString if something still in path
  1092. ## --- Buffered ---
  1093. width = self.apertures[last_path_aperture]["size"]
  1094. geo = LineString(path).buffer(width/2)
  1095. poly_buffer.append(geo)
  1096. # --- Apply buffer ---
  1097. if current_polarity == 'D':
  1098. self.solid_geometry = self.solid_geometry.union(cascaded_union(poly_buffer))
  1099. else:
  1100. self.solid_geometry = self.solid_geometry.difference(cascaded_union(poly_buffer))
  1101. except Exception, err:
  1102. #print traceback.format_exc()
  1103. log.error("PARSING FAILED. Line %d: %s" % (line_num, gline))
  1104. raise
  1105. @staticmethod
  1106. def create_flash_geometry(location, aperture):
  1107. if type(location) == list:
  1108. location = Point(location)
  1109. if aperture['type'] == 'C': # Circles
  1110. return location.buffer(aperture['size']/2)
  1111. if aperture['type'] == 'R': # Rectangles
  1112. loc = location.coords[0]
  1113. width = aperture['width']
  1114. height = aperture['height']
  1115. minx = loc[0] - width/2
  1116. maxx = loc[0] + width/2
  1117. miny = loc[1] - height/2
  1118. maxy = loc[1] + height/2
  1119. return shply_box(minx, miny, maxx, maxy)
  1120. if aperture['type'] == 'O': # Obround
  1121. loc = location.coords[0]
  1122. width = aperture['width']
  1123. height = aperture['height']
  1124. if width > height:
  1125. p1 = Point(loc[0] + 0.5*(width-height), loc[1])
  1126. p2 = Point(loc[0] - 0.5*(width-height), loc[1])
  1127. c1 = p1.buffer(height*0.5)
  1128. c2 = p2.buffer(height*0.5)
  1129. else:
  1130. p1 = Point(loc[0], loc[1] + 0.5*(height-width))
  1131. p2 = Point(loc[0], loc[1] - 0.5*(height-width))
  1132. c1 = p1.buffer(width*0.5)
  1133. c2 = p2.buffer(width*0.5)
  1134. return cascaded_union([c1, c2]).convex_hull
  1135. if aperture['type'] == 'P': # Regular polygon
  1136. loc = location.coords[0]
  1137. diam = aperture['diam']
  1138. n_vertices = aperture['nVertices']
  1139. points = []
  1140. for i in range(0, n_vertices):
  1141. x = loc[0] + diam * (cos(2 * pi * i / n_vertices))
  1142. y = loc[1] + diam * (sin(2 * pi * i / n_vertices))
  1143. points.append((x, y))
  1144. ply = Polygon(points)
  1145. if 'rotation' in aperture:
  1146. ply = affinity.rotate(ply, aperture['rotation'])
  1147. return ply
  1148. if aperture['type'] == 'AM': # Aperture Macro
  1149. loc = location.coords[0]
  1150. flash_geo = aperture['macro'].make_geometry(aperture['modifiers'])
  1151. return affinity.translate(flash_geo, xoff=loc[0], yoff=loc[1])
  1152. return None
  1153. def create_geometry(self):
  1154. """
  1155. Geometry from a Gerber file is made up entirely of polygons.
  1156. Every stroke (linear or circular) has an aperture which gives
  1157. it thickness. Additionally, aperture strokes have non-zero area,
  1158. and regions naturally do as well.
  1159. :rtype : None
  1160. :return: None
  1161. """
  1162. # self.buffer_paths()
  1163. #
  1164. # self.fix_regions()
  1165. #
  1166. # self.do_flashes()
  1167. #
  1168. # self.solid_geometry = cascaded_union(self.buffered_paths +
  1169. # [poly['polygon'] for poly in self.regions] +
  1170. # self.flash_geometry)
  1171. def get_bounding_box(self, margin=0.0, rounded=False):
  1172. """
  1173. Creates and returns a rectangular polygon bounding at a distance of
  1174. margin from the object's ``solid_geometry``. If margin > 0, the polygon
  1175. can optionally have rounded corners of radius equal to margin.
  1176. :param margin: Distance to enlarge the rectangular bounding
  1177. box in both positive and negative, x and y axes.
  1178. :type margin: float
  1179. :param rounded: Wether or not to have rounded corners.
  1180. :type rounded: bool
  1181. :return: The bounding box.
  1182. :rtype: Shapely.Polygon
  1183. """
  1184. bbox = self.solid_geometry.envelope.buffer(margin)
  1185. if not rounded:
  1186. bbox = bbox.envelope
  1187. return bbox
  1188. class Excellon(Geometry):
  1189. """
  1190. *ATTRIBUTES*
  1191. * ``tools`` (dict): The key is the tool name and the value is
  1192. a dictionary specifying the tool:
  1193. ================ ====================================
  1194. Key Value
  1195. ================ ====================================
  1196. C Diameter of the tool
  1197. Others Not supported (Ignored).
  1198. ================ ====================================
  1199. * ``drills`` (list): Each is a dictionary:
  1200. ================ ====================================
  1201. Key Value
  1202. ================ ====================================
  1203. point (Shapely.Point) Where to drill
  1204. tool (str) A key in ``tools``
  1205. ================ ====================================
  1206. """
  1207. def __init__(self):
  1208. """
  1209. The constructor takes no parameters.
  1210. :return: Excellon object.
  1211. :rtype: Excellon
  1212. """
  1213. Geometry.__init__(self)
  1214. self.tools = {}
  1215. self.drills = []
  1216. # Trailing "T" or leading "L" (default)
  1217. self.zeros = "T"
  1218. # Attributes to be included in serialization
  1219. # Always append to it because it carries contents
  1220. # from Geometry.
  1221. self.ser_attrs += ['tools', 'drills', 'zeros']
  1222. #### Patterns ####
  1223. # Regex basics:
  1224. # ^ - beginning
  1225. # $ - end
  1226. # *: 0 or more, +: 1 or more, ?: 0 or 1
  1227. # M48 - Beggining of Part Program Header
  1228. self.hbegin_re = re.compile(r'^M48$')
  1229. # M95 or % - End of Part Program Header
  1230. # NOTE: % has different meaning in the body
  1231. self.hend_re = re.compile(r'^(?:M95|%)$')
  1232. # FMAT Excellon format
  1233. self.fmat_re = re.compile(r'^FMAT,([12])$')
  1234. # Number format and units
  1235. # INCH uses 6 digits
  1236. # METRIC uses 5/6
  1237. self.units_re = re.compile(r'^(INCH|METRIC)(?:,([TL])Z)?$')
  1238. # Tool definition/parameters (?= is look-ahead
  1239. # NOTE: This might be an overkill!
  1240. # self.toolset_re = re.compile(r'^T(0?\d|\d\d)(?=.*C(\d*\.?\d*))?' +
  1241. # r'(?=.*F(\d*\.?\d*))?(?=.*S(\d*\.?\d*))?' +
  1242. # r'(?=.*B(\d*\.?\d*))?(?=.*H(\d*\.?\d*))?' +
  1243. # r'(?=.*Z([-\+]?\d*\.?\d*))?[CFSBHT]')
  1244. self.toolset_re = re.compile(r'^T(\d+)(?=.*C(\d*\.?\d*))?' +
  1245. r'(?=.*F(\d*\.?\d*))?(?=.*S(\d*\.?\d*))?' +
  1246. r'(?=.*B(\d*\.?\d*))?(?=.*H(\d*\.?\d*))?' +
  1247. r'(?=.*Z([-\+]?\d*\.?\d*))?[CFSBHT]')
  1248. # Tool select
  1249. # Can have additional data after tool number but
  1250. # is ignored if present in the header.
  1251. # Warning: This will match toolset_re too.
  1252. # self.toolsel_re = re.compile(r'^T((?:\d\d)|(?:\d))')
  1253. self.toolsel_re = re.compile(r'^T(\d+)')
  1254. # Comment
  1255. self.comm_re = re.compile(r'^;(.*)$')
  1256. # Absolute/Incremental G90/G91
  1257. self.absinc_re = re.compile(r'^G9([01])$')
  1258. # Modes of operation
  1259. # 1-linear, 2-circCW, 3-cirCCW, 4-vardwell, 5-Drill
  1260. self.modes_re = re.compile(r'^G0([012345])')
  1261. # Measuring mode
  1262. # 1-metric, 2-inch
  1263. self.meas_re = re.compile(r'^M7([12])$')
  1264. # Coordinates
  1265. #self.xcoord_re = re.compile(r'^X(\d*\.?\d*)(?:Y\d*\.?\d*)?$')
  1266. #self.ycoord_re = re.compile(r'^(?:X\d*\.?\d*)?Y(\d*\.?\d*)$')
  1267. self.coordsperiod_re = re.compile(r'(?=.*X([-\+]?\d*\.\d*))?(?=.*Y([-\+]?\d*\.\d*))?[XY]')
  1268. self.coordsnoperiod_re = re.compile(r'(?!.*\.)(?=.*X([-\+]?\d*))?(?=.*Y([-\+]?\d*))?[XY]')
  1269. # R - Repeat hole (# times, X offset, Y offset)
  1270. self.rep_re = re.compile(r'^R(\d+)(?=.*[XY])+(?:X([-\+]?\d*\.?\d*))?(?:Y([-\+]?\d*\.?\d*))?$')
  1271. # Various stop/pause commands
  1272. self.stop_re = re.compile(r'^((G04)|(M09)|(M06)|(M00)|(M30))')
  1273. # Parse coordinates
  1274. self.leadingzeros_re = re.compile(r'^(0*)(\d*)')
  1275. def parse_file(self, filename):
  1276. """
  1277. Reads the specified file as array of lines as
  1278. passes it to ``parse_lines()``.
  1279. :param filename: The file to be read and parsed.
  1280. :type filename: str
  1281. :return: None
  1282. """
  1283. efile = open(filename, 'r')
  1284. estr = efile.readlines()
  1285. efile.close()
  1286. self.parse_lines(estr)
  1287. def parse_lines(self, elines):
  1288. """
  1289. Main Excellon parser.
  1290. :param elines: List of strings, each being a line of Excellon code.
  1291. :type elines: list
  1292. :return: None
  1293. """
  1294. # State variables
  1295. current_tool = ""
  1296. in_header = False
  1297. current_x = None
  1298. current_y = None
  1299. line_num = 0 # Line number
  1300. for eline in elines:
  1301. line_num += 1
  1302. ### Cleanup lines
  1303. eline = eline.strip(' \r\n')
  1304. ## Header Begin/End ##
  1305. if self.hbegin_re.search(eline):
  1306. in_header = True
  1307. continue
  1308. if self.hend_re.search(eline):
  1309. in_header = False
  1310. continue
  1311. #### Body ####
  1312. if not in_header:
  1313. ## Tool change ##
  1314. match = self.toolsel_re.search(eline)
  1315. if match:
  1316. current_tool = str(int(match.group(1)))
  1317. continue
  1318. ## Coordinates without period ##
  1319. match = self.coordsnoperiod_re.search(eline)
  1320. if match:
  1321. try:
  1322. #x = float(match.group(1))/10000
  1323. x = self.parse_number(match.group(1))
  1324. current_x = x
  1325. except TypeError:
  1326. x = current_x
  1327. try:
  1328. #y = float(match.group(2))/10000
  1329. y = self.parse_number(match.group(2))
  1330. current_y = y
  1331. except TypeError:
  1332. y = current_y
  1333. if x is None or y is None:
  1334. log.error("Missing coordinates")
  1335. continue
  1336. self.drills.append({'point': Point((x, y)), 'tool': current_tool})
  1337. continue
  1338. ## Coordinates with period: Use literally. ##
  1339. match = self.coordsperiod_re.search(eline)
  1340. if match:
  1341. try:
  1342. x = float(match.group(1))
  1343. current_x = x
  1344. except TypeError:
  1345. x = current_x
  1346. try:
  1347. y = float(match.group(2))
  1348. current_y = y
  1349. except TypeError:
  1350. y = current_y
  1351. if x is None or y is None:
  1352. log.error("Missing coordinates")
  1353. continue
  1354. self.drills.append({'point': Point((x, y)), 'tool': current_tool})
  1355. continue
  1356. #### Header ####
  1357. if in_header:
  1358. ## Tool definitions ##
  1359. match = self.toolset_re.search(eline)
  1360. if match:
  1361. name = str(int(match.group(1)))
  1362. spec = {
  1363. "C": float(match.group(2)),
  1364. # "F": float(match.group(3)),
  1365. # "S": float(match.group(4)),
  1366. # "B": float(match.group(5)),
  1367. # "H": float(match.group(6)),
  1368. # "Z": float(match.group(7))
  1369. }
  1370. self.tools[name] = spec
  1371. continue
  1372. ## Units and number format ##
  1373. match = self.units_re.match(eline)
  1374. if match:
  1375. self.zeros = match.group(2) # "T" or "L"
  1376. self.units = {"INCH": "IN", "METRIC": "MM"}[match.group(1)]
  1377. continue
  1378. log.warning("Line ignored: %s" % eline)
  1379. log.info("Zeros: %s, Units %s." % (self.zeros, self.units))
  1380. def parse_number(self, number_str):
  1381. """
  1382. Parses coordinate numbers without period.
  1383. :param number_str: String representing the numerical value.
  1384. :type number_str: str
  1385. :return: Floating point representation of the number
  1386. :rtype: foat
  1387. """
  1388. if self.zeros == "L":
  1389. match = self.leadingzeros_re.search(number_str)
  1390. return float(number_str)/(10**(len(match.group(2))-2+len(match.group(1))))
  1391. else: # Trailing
  1392. return float(number_str)/10000
  1393. def create_geometry(self):
  1394. """
  1395. Creates circles of the tool diameter at every point
  1396. specified in ``self.drills``.
  1397. :return: None
  1398. """
  1399. self.solid_geometry = []
  1400. for drill in self.drills:
  1401. #poly = drill['point'].buffer(self.tools[drill['tool']]["C"]/2.0)
  1402. tooldia = self.tools[drill['tool']]['C']
  1403. poly = drill['point'].buffer(tooldia/2.0)
  1404. self.solid_geometry.append(poly)
  1405. def scale(self, factor):
  1406. """
  1407. Scales geometry on the XY plane in the object by a given factor.
  1408. Tool sizes, feedrates an Z-plane dimensions are untouched.
  1409. :param factor: Number by which to scale the object.
  1410. :type factor: float
  1411. :return: None
  1412. :rtype: NOne
  1413. """
  1414. # Drills
  1415. for drill in self.drills:
  1416. drill['point'] = affinity.scale(drill['point'], factor, factor, origin=(0, 0))
  1417. self.create_geometry()
  1418. def offset(self, vect):
  1419. """
  1420. Offsets geometry on the XY plane in the object by a given vector.
  1421. :param vect: (x, y) offset vector.
  1422. :type vect: tuple
  1423. :return: None
  1424. """
  1425. dx, dy = vect
  1426. # Drills
  1427. for drill in self.drills:
  1428. drill['point'] = affinity.translate(drill['point'], xoff=dx, yoff=dy)
  1429. # Recreate geometry
  1430. self.create_geometry()
  1431. def mirror(self, axis, point):
  1432. """
  1433. :param axis: "X" or "Y" indicates around which axis to mirror.
  1434. :type axis: str
  1435. :param point: [x, y] point belonging to the mirror axis.
  1436. :type point: list
  1437. :return: None
  1438. """
  1439. px, py = point
  1440. xscale, yscale = {"X": (1.0, -1.0), "Y": (-1.0, 1.0)}[axis]
  1441. # Modify data
  1442. for drill in self.drills:
  1443. drill['point'] = affinity.scale(drill['point'], xscale, yscale, origin=(px, py))
  1444. # Recreate geometry
  1445. self.create_geometry()
  1446. def convert_units(self, units):
  1447. factor = Geometry.convert_units(self, units)
  1448. # Tools
  1449. for tname in self.tools:
  1450. self.tools[tname]["C"] *= factor
  1451. self.create_geometry()
  1452. return factor
  1453. class CNCjob(Geometry):
  1454. """
  1455. Represents work to be done by a CNC machine.
  1456. *ATTRIBUTES*
  1457. * ``gcode_parsed`` (list): Each is a dictionary:
  1458. ===================== =========================================
  1459. Key Value
  1460. ===================== =========================================
  1461. geom (Shapely.LineString) Tool path (XY plane)
  1462. kind (string) "AB", A is "T" (travel) or
  1463. "C" (cut). B is "F" (fast) or "S" (slow).
  1464. ===================== =========================================
  1465. """
  1466. def __init__(self, units="in", kind="generic", z_move=0.1,
  1467. feedrate=3.0, z_cut=-0.002, tooldia=0.0):
  1468. Geometry.__init__(self)
  1469. self.kind = kind
  1470. self.units = units
  1471. self.z_cut = z_cut
  1472. self.z_move = z_move
  1473. self.feedrate = feedrate
  1474. self.tooldia = tooldia
  1475. self.unitcode = {"IN": "G20", "MM": "G21"}
  1476. self.pausecode = "G04 P1"
  1477. self.feedminutecode = "G94"
  1478. self.absolutecode = "G90"
  1479. self.gcode = ""
  1480. self.input_geometry_bounds = None
  1481. self.gcode_parsed = None
  1482. self.steps_per_circ = 20 # Used when parsing G-code arcs
  1483. # Attributes to be included in serialization
  1484. # Always append to it because it carries contents
  1485. # from Geometry.
  1486. self.ser_attrs += ['kind', 'z_cut', 'z_move', 'feedrate', 'tooldia',
  1487. 'gcode', 'input_geometry_bounds', 'gcode_parsed',
  1488. 'steps_per_circ']
  1489. def convert_units(self, units):
  1490. factor = Geometry.convert_units(self, units)
  1491. log.debug("CNCjob.convert_units()")
  1492. self.z_cut *= factor
  1493. self.z_move *= factor
  1494. self.feedrate *= factor
  1495. self.tooldia *= factor
  1496. return factor
  1497. def generate_from_excellon(self, exobj):
  1498. """
  1499. Generates G-code for drilling from Excellon object.
  1500. self.gcode becomes a list, each element is a
  1501. different job for each tool in the excellon code.
  1502. """
  1503. self.kind = "drill"
  1504. self.gcode = []
  1505. t = "G00 X%.4fY%.4f\n"
  1506. down = "G01 Z%.4f\n" % self.z_cut
  1507. up = "G01 Z%.4f\n" % self.z_move
  1508. for tool in exobj.tools:
  1509. points = []
  1510. for drill in exobj.drill:
  1511. if drill['tool'] == tool:
  1512. points.append(drill['point'])
  1513. gcode = self.unitcode[self.units.upper()] + "\n"
  1514. gcode += self.absolutecode + "\n"
  1515. gcode += self.feedminutecode + "\n"
  1516. gcode += "F%.2f\n" % self.feedrate
  1517. gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  1518. gcode += "M03\n" # Spindle start
  1519. gcode += self.pausecode + "\n"
  1520. for point in points:
  1521. gcode += t % point
  1522. gcode += down + up
  1523. gcode += t % (0, 0)
  1524. gcode += "M05\n" # Spindle stop
  1525. self.gcode.append(gcode)
  1526. def generate_from_excellon_by_tool(self, exobj, tools="all"):
  1527. """
  1528. Creates gcode for this object from an Excellon object
  1529. for the specified tools.
  1530. :param exobj: Excellon object to process
  1531. :type exobj: Excellon
  1532. :param tools: Comma separated tool names
  1533. :type: tools: str
  1534. :return: None
  1535. :rtype: None
  1536. """
  1537. log.debug("Creating CNC Job from Excellon...")
  1538. if tools == "all":
  1539. tools = [tool for tool in exobj.tools]
  1540. else:
  1541. tools = [x.strip() for x in tools.split(",")]
  1542. tools = filter(lambda i: i in exobj.tools, tools)
  1543. log.debug("Tools are: %s" % str(tools))
  1544. points = []
  1545. for drill in exobj.drills:
  1546. if drill['tool'] in tools:
  1547. points.append(drill['point'])
  1548. log.debug("Found %d drills." % len(points))
  1549. #self.kind = "drill"
  1550. self.gcode = []
  1551. t = "G00 X%.4fY%.4f\n"
  1552. down = "G01 Z%.4f\n" % self.z_cut
  1553. up = "G01 Z%.4f\n" % self.z_move
  1554. gcode = self.unitcode[self.units.upper()] + "\n"
  1555. gcode += self.absolutecode + "\n"
  1556. gcode += self.feedminutecode + "\n"
  1557. gcode += "F%.2f\n" % self.feedrate
  1558. gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  1559. gcode += "M03\n" # Spindle start
  1560. gcode += self.pausecode + "\n"
  1561. for point in points:
  1562. x, y = point.coords.xy
  1563. gcode += t % (x[0], y[0])
  1564. gcode += down + up
  1565. gcode += t % (0, 0)
  1566. gcode += "M05\n" # Spindle stop
  1567. self.gcode = gcode
  1568. def generate_from_geometry(self, geometry, append=True, tooldia=None, tolerance=0):
  1569. """
  1570. Generates G-Code from a Geometry object. Stores in ``self.gcode``.
  1571. :param geometry: Geometry defining the toolpath
  1572. :type geometry: Geometry
  1573. :param append: Wether to append to self.gcode or re-write it.
  1574. :type append: bool
  1575. :param tooldia: If given, sets the tooldia property but does
  1576. not affect the process in any other way.
  1577. :type tooldia: bool
  1578. :param tolerance: All points in the simplified object will be within the
  1579. tolerance distance of the original geometry.
  1580. :return: None
  1581. :rtype: None
  1582. """
  1583. if tooldia is not None:
  1584. self.tooldia = tooldia
  1585. self.input_geometry_bounds = geometry.bounds()
  1586. if not append:
  1587. self.gcode = ""
  1588. self.gcode = self.unitcode[self.units.upper()] + "\n"
  1589. self.gcode += self.absolutecode + "\n"
  1590. self.gcode += self.feedminutecode + "\n"
  1591. self.gcode += "F%.2f\n" % self.feedrate
  1592. self.gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  1593. self.gcode += "M03\n" # Spindle start
  1594. self.gcode += self.pausecode + "\n"
  1595. for geo in geometry.solid_geometry:
  1596. if type(geo) == Polygon:
  1597. self.gcode += self.polygon2gcode(geo, tolerance=tolerance)
  1598. continue
  1599. if type(geo) == LineString or type(geo) == LinearRing:
  1600. self.gcode += self.linear2gcode(geo, tolerance=tolerance)
  1601. continue
  1602. if type(geo) == Point:
  1603. # TODO: point2gcode does not return anything...
  1604. self.gcode += self.point2gcode(geo)
  1605. continue
  1606. if type(geo) == MultiPolygon:
  1607. for poly in geo:
  1608. self.gcode += self.polygon2gcode(poly, tolerance=tolerance)
  1609. continue
  1610. log.warning("G-code generation not implemented for %s" % (str(type(geo))))
  1611. self.gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1612. self.gcode += "G00 X0Y0\n"
  1613. self.gcode += "M05\n" # Spindle stop
  1614. def pre_parse(self, gtext):
  1615. """
  1616. Separates parts of the G-Code text into a list of dictionaries.
  1617. Used by ``self.gcode_parse()``.
  1618. :param gtext: A single string with g-code
  1619. """
  1620. # Units: G20-inches, G21-mm
  1621. units_re = re.compile(r'^G2([01])')
  1622. # TODO: This has to be re-done
  1623. gcmds = []
  1624. lines = gtext.split("\n") # TODO: This is probably a lot of work!
  1625. for line in lines:
  1626. # Clean up
  1627. line = line.strip()
  1628. # Remove comments
  1629. # NOTE: Limited to 1 bracket pair
  1630. op = line.find("(")
  1631. cl = line.find(")")
  1632. #if op > -1 and cl > op:
  1633. if cl > op > -1:
  1634. #comment = line[op+1:cl]
  1635. line = line[:op] + line[(cl+1):]
  1636. # Units
  1637. match = units_re.match(line)
  1638. if match:
  1639. self.units = {'0': "IN", '1': "MM"}[match.group(1)]
  1640. # Parse GCode
  1641. # 0 4 12
  1642. # G01 X-0.007 Y-0.057
  1643. # --> codes_idx = [0, 4, 12]
  1644. codes = "NMGXYZIJFP"
  1645. codes_idx = []
  1646. i = 0
  1647. for ch in line:
  1648. if ch in codes:
  1649. codes_idx.append(i)
  1650. i += 1
  1651. n_codes = len(codes_idx)
  1652. if n_codes == 0:
  1653. continue
  1654. # Separate codes in line
  1655. parts = []
  1656. for p in range(n_codes-1):
  1657. parts.append(line[codes_idx[p]:codes_idx[p+1]].strip())
  1658. parts.append(line[codes_idx[-1]:].strip())
  1659. # Separate codes from values
  1660. cmds = {}
  1661. for part in parts:
  1662. cmds[part[0]] = float(part[1:])
  1663. gcmds.append(cmds)
  1664. return gcmds
  1665. def gcode_parse(self):
  1666. """
  1667. G-Code parser (from self.gcode). Generates dictionary with
  1668. single-segment LineString's and "kind" indicating cut or travel,
  1669. fast or feedrate speed.
  1670. """
  1671. kind = ["C", "F"] # T=travel, C=cut, F=fast, S=slow
  1672. # Results go here
  1673. geometry = []
  1674. # TODO: Merge into single parser?
  1675. gobjs = self.pre_parse(self.gcode)
  1676. # Last known instruction
  1677. current = {'X': 0.0, 'Y': 0.0, 'Z': 0.0, 'G': 0}
  1678. # Current path: temporary storage until tool is
  1679. # lifted or lowered.
  1680. path = [(0, 0)]
  1681. # Process every instruction
  1682. for gobj in gobjs:
  1683. ## Changing height
  1684. if 'Z' in gobj:
  1685. if ('X' in gobj or 'Y' in gobj) and gobj['Z'] != current['Z']:
  1686. log.warning("Non-orthogonal motion: From %s" % str(current))
  1687. log.warning(" To: %s" % str(gobj))
  1688. current['Z'] = gobj['Z']
  1689. # Store the path into geometry and reset path
  1690. if len(path) > 1:
  1691. geometry.append({"geom": LineString(path),
  1692. "kind": kind})
  1693. path = [path[-1]] # Start with the last point of last path.
  1694. if 'G' in gobj:
  1695. current['G'] = int(gobj['G'])
  1696. if 'X' in gobj or 'Y' in gobj:
  1697. if 'X' in gobj:
  1698. x = gobj['X']
  1699. else:
  1700. x = current['X']
  1701. if 'Y' in gobj:
  1702. y = gobj['Y']
  1703. else:
  1704. y = current['Y']
  1705. kind = ["C", "F"] # T=travel, C=cut, F=fast, S=slow
  1706. if current['Z'] > 0:
  1707. kind[0] = 'T'
  1708. if current['G'] > 0:
  1709. kind[1] = 'S'
  1710. arcdir = [None, None, "cw", "ccw"]
  1711. if current['G'] in [0, 1]: # line
  1712. path.append((x, y))
  1713. if current['G'] in [2, 3]: # arc
  1714. center = [gobj['I'] + current['X'], gobj['J'] + current['Y']]
  1715. radius = sqrt(gobj['I']**2 + gobj['J']**2)
  1716. start = arctan2(-gobj['J'], -gobj['I'])
  1717. stop = arctan2(-center[1]+y, -center[0]+x)
  1718. path += arc(center, radius, start, stop,
  1719. arcdir[current['G']],
  1720. self.steps_per_circ)
  1721. # Update current instruction
  1722. for code in gobj:
  1723. current[code] = gobj[code]
  1724. # There might not be a change in height at the
  1725. # end, therefore, see here too if there is
  1726. # a final path.
  1727. if len(path) > 1:
  1728. geometry.append({"geom": LineString(path),
  1729. "kind": kind})
  1730. self.gcode_parsed = geometry
  1731. return geometry
  1732. # def plot(self, tooldia=None, dpi=75, margin=0.1,
  1733. # color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]},
  1734. # alpha={"T": 0.3, "C": 1.0}):
  1735. # """
  1736. # Creates a Matplotlib figure with a plot of the
  1737. # G-code job.
  1738. # """
  1739. # if tooldia is None:
  1740. # tooldia = self.tooldia
  1741. #
  1742. # fig = Figure(dpi=dpi)
  1743. # ax = fig.add_subplot(111)
  1744. # ax.set_aspect(1)
  1745. # xmin, ymin, xmax, ymax = self.input_geometry_bounds
  1746. # ax.set_xlim(xmin-margin, xmax+margin)
  1747. # ax.set_ylim(ymin-margin, ymax+margin)
  1748. #
  1749. # if tooldia == 0:
  1750. # for geo in self.gcode_parsed:
  1751. # linespec = '--'
  1752. # linecolor = color[geo['kind'][0]][1]
  1753. # if geo['kind'][0] == 'C':
  1754. # linespec = 'k-'
  1755. # x, y = geo['geom'].coords.xy
  1756. # ax.plot(x, y, linespec, color=linecolor)
  1757. # else:
  1758. # for geo in self.gcode_parsed:
  1759. # poly = geo['geom'].buffer(tooldia/2.0)
  1760. # patch = PolygonPatch(poly, facecolor=color[geo['kind'][0]][0],
  1761. # edgecolor=color[geo['kind'][0]][1],
  1762. # alpha=alpha[geo['kind'][0]], zorder=2)
  1763. # ax.add_patch(patch)
  1764. #
  1765. # return fig
  1766. def plot2(self, axes, tooldia=None, dpi=75, margin=0.1,
  1767. color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]},
  1768. alpha={"T": 0.3, "C": 1.0}, tool_tolerance=0.0005):
  1769. """
  1770. Plots the G-code job onto the given axes.
  1771. :param axes: Matplotlib axes on which to plot.
  1772. :param tooldia: Tool diameter.
  1773. :param dpi: Not used!
  1774. :param margin: Not used!
  1775. :param color: Color specification.
  1776. :param alpha: Transparency specification.
  1777. :param tool_tolerance: Tolerance when drawing the toolshape.
  1778. :return: None
  1779. """
  1780. if tooldia is None:
  1781. tooldia = self.tooldia
  1782. if tooldia == 0:
  1783. for geo in self.gcode_parsed:
  1784. linespec = '--'
  1785. linecolor = color[geo['kind'][0]][1]
  1786. if geo['kind'][0] == 'C':
  1787. linespec = 'k-'
  1788. x, y = geo['geom'].coords.xy
  1789. axes.plot(x, y, linespec, color=linecolor)
  1790. else:
  1791. for geo in self.gcode_parsed:
  1792. poly = geo['geom'].buffer(tooldia/2.0).simplify(tool_tolerance)
  1793. patch = PolygonPatch(poly, facecolor=color[geo['kind'][0]][0],
  1794. edgecolor=color[geo['kind'][0]][1],
  1795. alpha=alpha[geo['kind'][0]], zorder=2)
  1796. axes.add_patch(patch)
  1797. def create_geometry(self):
  1798. # TODO: This takes forever. Too much data?
  1799. self.solid_geometry = cascaded_union([geo['geom'] for geo in self.gcode_parsed])
  1800. def polygon2gcode(self, polygon, tolerance=0):
  1801. """
  1802. Creates G-Code for the exterior and all interior paths
  1803. of a polygon.
  1804. :param polygon: A Shapely.Polygon
  1805. :type polygon: Shapely.Polygon
  1806. :param tolerance: All points in the simplified object will be within the
  1807. tolerance distance of the original geometry.
  1808. :type tolerance: float
  1809. :return: G-code to cut along polygon.
  1810. :rtype: str
  1811. """
  1812. if tolerance > 0:
  1813. target_polygon = polygon.simplify(tolerance)
  1814. else:
  1815. target_polygon = polygon
  1816. gcode = ""
  1817. t = "G0%d X%.4fY%.4f\n"
  1818. path = list(target_polygon.exterior.coords) # Polygon exterior
  1819. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1820. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1821. for pt in path[1:]:
  1822. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  1823. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1824. for ints in target_polygon.interiors: # Polygon interiors
  1825. path = list(ints.coords)
  1826. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1827. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1828. for pt in path[1:]:
  1829. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  1830. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1831. return gcode
  1832. def linear2gcode(self, linear, tolerance=0):
  1833. """
  1834. Generates G-code to cut along the linear feature.
  1835. :param linear: The path to cut along.
  1836. :type: Shapely.LinearRing or Shapely.Linear String
  1837. :param tolerance: All points in the simplified object will be within the
  1838. tolerance distance of the original geometry.
  1839. :type tolerance: float
  1840. :return: G-code to cut alon the linear feature.
  1841. :rtype: str
  1842. """
  1843. if tolerance > 0:
  1844. target_linear = linear.simplify(tolerance)
  1845. else:
  1846. target_linear = linear
  1847. gcode = ""
  1848. t = "G0%d X%.4fY%.4f\n"
  1849. path = list(target_linear.coords)
  1850. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1851. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1852. for pt in path[1:]:
  1853. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  1854. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1855. return gcode
  1856. def point2gcode(self, point):
  1857. # TODO: This is not doing anything.
  1858. gcode = ""
  1859. t = "G0%d X%.4fY%.4f\n"
  1860. path = list(point.coords)
  1861. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1862. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1863. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1864. def scale(self, factor):
  1865. """
  1866. Scales all the geometry on the XY plane in the object by the
  1867. given factor. Tool sizes, feedrates, or Z-axis dimensions are
  1868. not altered.
  1869. :param factor: Number by which to scale the object.
  1870. :type factor: float
  1871. :return: None
  1872. :rtype: None
  1873. """
  1874. for g in self.gcode_parsed:
  1875. g['geom'] = affinity.scale(g['geom'], factor, factor, origin=(0, 0))
  1876. self.create_geometry()
  1877. def offset(self, vect):
  1878. """
  1879. Offsets all the geometry on the XY plane in the object by the
  1880. given vector.
  1881. :param vect: (x, y) offset vector.
  1882. :type vect: tuple
  1883. :return: None
  1884. """
  1885. dx, dy = vect
  1886. for g in self.gcode_parsed:
  1887. g['geom'] = affinity.translate(g['geom'], xoff=dx, yoff=dy)
  1888. self.create_geometry()
  1889. # def get_bounds(geometry_set):
  1890. # xmin = Inf
  1891. # ymin = Inf
  1892. # xmax = -Inf
  1893. # ymax = -Inf
  1894. #
  1895. # #print "Getting bounds of:", str(geometry_set)
  1896. # for gs in geometry_set:
  1897. # try:
  1898. # gxmin, gymin, gxmax, gymax = geometry_set[gs].bounds()
  1899. # xmin = min([xmin, gxmin])
  1900. # ymin = min([ymin, gymin])
  1901. # xmax = max([xmax, gxmax])
  1902. # ymax = max([ymax, gymax])
  1903. # except:
  1904. # print "DEV WARNING: Tried to get bounds of empty geometry."
  1905. #
  1906. # return [xmin, ymin, xmax, ymax]
  1907. def get_bounds(geometry_list):
  1908. xmin = Inf
  1909. ymin = Inf
  1910. xmax = -Inf
  1911. ymax = -Inf
  1912. #print "Getting bounds of:", str(geometry_set)
  1913. for gs in geometry_list:
  1914. try:
  1915. gxmin, gymin, gxmax, gymax = gs.bounds()
  1916. xmin = min([xmin, gxmin])
  1917. ymin = min([ymin, gymin])
  1918. xmax = max([xmax, gxmax])
  1919. ymax = max([ymax, gymax])
  1920. except:
  1921. log.warning("DEVELOPMENT: Tried to get bounds of empty geometry.")
  1922. return [xmin, ymin, xmax, ymax]
  1923. def arc(center, radius, start, stop, direction, steps_per_circ):
  1924. """
  1925. Creates a list of point along the specified arc.
  1926. :param center: Coordinates of the center [x, y]
  1927. :type center: list
  1928. :param radius: Radius of the arc.
  1929. :type radius: float
  1930. :param start: Starting angle in radians
  1931. :type start: float
  1932. :param stop: End angle in radians
  1933. :type stop: float
  1934. :param direction: Orientation of the arc, "CW" or "CCW"
  1935. :type direction: string
  1936. :param steps_per_circ: Number of straight line segments to
  1937. represent a circle.
  1938. :type steps_per_circ: int
  1939. :return: The desired arc, as list of tuples
  1940. :rtype: list
  1941. """
  1942. # TODO: Resolution should be established by fraction of total length, not angle.
  1943. da_sign = {"cw": -1.0, "ccw": 1.0}
  1944. points = []
  1945. if direction == "ccw" and stop <= start:
  1946. stop += 2*pi
  1947. if direction == "cw" and stop >= start:
  1948. stop -= 2*pi
  1949. angle = abs(stop - start)
  1950. #angle = stop-start
  1951. steps = max([int(ceil(angle/(2*pi)*steps_per_circ)), 2])
  1952. delta_angle = da_sign[direction]*angle*1.0/steps
  1953. for i in range(steps+1):
  1954. theta = start + delta_angle*i
  1955. points.append((center[0]+radius*cos(theta), center[1]+radius*sin(theta)))
  1956. return points
  1957. def clear_poly(poly, tooldia, overlap=0.1):
  1958. """
  1959. Creates a list of Shapely geometry objects covering the inside
  1960. of a Shapely.Polygon. Use for removing all the copper in a region
  1961. or bed flattening.
  1962. :param poly: Target polygon
  1963. :type poly: Shapely.Polygon
  1964. :param tooldia: Diameter of the tool
  1965. :type tooldia: float
  1966. :param overlap: Fraction of the tool diameter to overlap
  1967. in each pass.
  1968. :type overlap: float
  1969. :return: list of Shapely.Polygon
  1970. :rtype: list
  1971. """
  1972. poly_cuts = [poly.buffer(-tooldia/2.0)]
  1973. while True:
  1974. poly = poly_cuts[-1].buffer(-tooldia*(1-overlap))
  1975. if poly.area > 0:
  1976. poly_cuts.append(poly)
  1977. else:
  1978. break
  1979. return poly_cuts
  1980. def find_polygon(poly_set, point):
  1981. """
  1982. Return the first polygon in the list of polygons poly_set
  1983. that contains the given point.
  1984. """
  1985. p = Point(point)
  1986. for poly in poly_set:
  1987. if poly.contains(p):
  1988. return poly
  1989. return None
  1990. def to_dict(obj):
  1991. """
  1992. Makes a Shapely geometry object into serializeable form.
  1993. :param obj: Shapely geometry.
  1994. :type obj: BaseGeometry
  1995. :return: Dictionary with serializable form if ``obj`` was
  1996. BaseGeometry or ApertureMacro, otherwise returns ``obj``.
  1997. """
  1998. if isinstance(obj, ApertureMacro):
  1999. return {
  2000. "__class__": "ApertureMacro",
  2001. "__inst__": obj.to_dict()
  2002. }
  2003. if isinstance(obj, BaseGeometry):
  2004. return {
  2005. "__class__": "Shply",
  2006. "__inst__": sdumps(obj)
  2007. }
  2008. return obj
  2009. def dict2obj(d):
  2010. """
  2011. Default deserializer.
  2012. :param d: Serializable dictionary representation of an object
  2013. to be reconstructed.
  2014. :return: Reconstructed object.
  2015. """
  2016. if '__class__' in d and '__inst__' in d:
  2017. if d['__class__'] == "Shply":
  2018. return sloads(d['__inst__'])
  2019. if d['__class__'] == "ApertureMacro":
  2020. am = ApertureMacro()
  2021. am.from_dict(d['__inst__'])
  2022. return am
  2023. return d
  2024. else:
  2025. return d
  2026. def plotg(geo):
  2027. try:
  2028. _ = iter(geo)
  2029. except:
  2030. geo = [geo]
  2031. for g in geo:
  2032. if type(g) == Polygon:
  2033. x, y = g.exterior.coords.xy
  2034. plot(x, y)
  2035. for ints in g.interiors:
  2036. x, y = ints.coords.xy
  2037. plot(x, y)
  2038. continue
  2039. if type(g) == LineString or type(g) == LinearRing:
  2040. x, y = g.coords.xy
  2041. plot(x, y)
  2042. continue
  2043. if type(g) == Point:
  2044. x, y = g.coords.xy
  2045. plot(x, y, 'o')
  2046. continue
  2047. try:
  2048. _ = iter(g)
  2049. plotg(g)
  2050. except:
  2051. log.error("Cannot plot: " + str(type(g)))
  2052. continue
  2053. def parse_gerber_number(strnumber, frac_digits):
  2054. """
  2055. Parse a single number of Gerber coordinates.
  2056. :param strnumber: String containing a number in decimal digits
  2057. from a coordinate data block, possibly with a leading sign.
  2058. :type strnumber: str
  2059. :param frac_digits: Number of digits used for the fractional
  2060. part of the number
  2061. :type frac_digits: int
  2062. :return: The number in floating point.
  2063. :rtype: float
  2064. """
  2065. return int(strnumber)*(10**(-frac_digits))