camlib.py 86 KB

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