camlib.py 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871
  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. from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos
  9. from matplotlib.figure import Figure
  10. import re
  11. # See: http://toblerity.org/shapely/manual.html
  12. from shapely.geometry import Polygon, LineString, Point, LinearRing
  13. from shapely.geometry import MultiPoint, MultiPolygon
  14. from shapely.geometry import box as shply_box
  15. from shapely.ops import cascaded_union
  16. import shapely.affinity as affinity
  17. from shapely.wkt import loads as sloads
  18. from shapely.wkt import dumps as sdumps
  19. from shapely.geometry.base import BaseGeometry
  20. # Used for solid polygons in Matplotlib
  21. from descartes.patch import PolygonPatch
  22. import simplejson as json
  23. # TODO: Commented for FlatCAM packaging with cx_freeze
  24. #from matplotlib.pyplot import plot
  25. class Geometry:
  26. def __init__(self):
  27. # Units (in or mm)
  28. self.units = 'in'
  29. # Final geometry: MultiPolygon
  30. self.solid_geometry = None
  31. # Attributes to be included in serialization
  32. self.ser_attrs = ['units', 'solid_geometry']
  33. def isolation_geometry(self, offset):
  34. """
  35. Creates contours around geometry at a given
  36. offset distance.
  37. :param offset: Offset distance.
  38. :type offset: float
  39. :return: The buffered geometry.
  40. :rtype: Shapely.MultiPolygon or Shapely.Polygon
  41. """
  42. return self.solid_geometry.buffer(offset)
  43. def bounds(self):
  44. """
  45. Returns coordinates of rectangular bounds
  46. of geometry: (xmin, ymin, xmax, ymax).
  47. """
  48. if self.solid_geometry is None:
  49. print "Warning: solid_geometry not computed yet."
  50. return (0, 0, 0, 0)
  51. if type(self.solid_geometry) == list:
  52. # TODO: This can be done faster. See comment from Shapely mailing lists.
  53. return cascaded_union(self.solid_geometry).bounds
  54. else:
  55. return self.solid_geometry.bounds
  56. def size(self):
  57. """
  58. Returns (width, height) of rectangular
  59. bounds of geometry.
  60. """
  61. if self.solid_geometry is None:
  62. print "Warning: solid_geometry not computed yet."
  63. return 0
  64. bounds = self.bounds()
  65. return (bounds[2]-bounds[0], bounds[3]-bounds[1])
  66. def get_empty_area(self, boundary=None):
  67. """
  68. Returns the complement of self.solid_geometry within
  69. the given boundary polygon. If not specified, it defaults to
  70. the rectangular bounding box of self.solid_geometry.
  71. """
  72. if boundary is None:
  73. boundary = self.solid_geometry.envelope
  74. return boundary.difference(self.solid_geometry)
  75. def clear_polygon(self, polygon, tooldia, overlap=0.15):
  76. """
  77. Creates geometry inside a polygon for a tool to cover
  78. the whole area.
  79. """
  80. poly_cuts = [polygon.buffer(-tooldia/2.0)]
  81. while True:
  82. polygon = poly_cuts[-1].buffer(-tooldia*(1-overlap))
  83. if polygon.area > 0:
  84. poly_cuts.append(polygon)
  85. else:
  86. break
  87. return poly_cuts
  88. def scale(self, factor):
  89. """
  90. Scales all of the object's geometry by a given factor. Override
  91. this method.
  92. :param factor: Number by which to scale.
  93. :type factor: float
  94. :return: None
  95. :rtype: None
  96. """
  97. return
  98. def offset(self, vect):
  99. """
  100. Offset the geometry by the given vector. Override this method.
  101. :param vect: (x, y) vector by which to offset the object.
  102. :type vect: tuple
  103. :return: None
  104. """
  105. return
  106. def convert_units(self, units):
  107. """
  108. Converts the units of the object to ``units`` by scaling all
  109. the geometry appropriately. This call ``scale()``. Don't call
  110. it again in descendents.
  111. :param units: "IN" or "MM"
  112. :type units: str
  113. :return: Scaling factor resulting from unit change.
  114. :rtype: float
  115. """
  116. print "Geometry.convert_units()"
  117. if units.upper() == self.units.upper():
  118. return 1.0
  119. if units.upper() == "MM":
  120. factor = 25.4
  121. elif units.upper() == "IN":
  122. factor = 1/25.4
  123. else:
  124. print "Unsupported units:", units
  125. return 1.0
  126. self.units = units
  127. self.scale(factor)
  128. return factor
  129. def to_dict(self):
  130. """
  131. Returns a respresentation of the object as a dictionary.
  132. Attributes to include are listed in ``self.ser_attrs``.
  133. :return: A dictionary-encoded copy of the object.
  134. :rtype: dict
  135. """
  136. d = {}
  137. for attr in self.ser_attrs:
  138. d[attr] = getattr(self, attr)
  139. return d
  140. def from_dict(self, d):
  141. """
  142. Sets object's attributes from a dictionary.
  143. Attributes to include are listed in ``self.ser_attrs``.
  144. This method will look only for only and all the
  145. attributes in ``self.ser_attrs``. They must all
  146. be present. Use only for deserializing saved
  147. objects.
  148. :param d: Dictionary of attributes to set in the object.
  149. :type d: dict
  150. :return: None
  151. """
  152. for attr in self.ser_attrs:
  153. setattr(self, attr, d[attr])
  154. class Gerber (Geometry):
  155. """
  156. **ATTRIBUTES**
  157. * ``apertures`` (dict): The keys are names/identifiers of each aperture.
  158. The values are dictionaries key/value pairs which describe the aperture. The
  159. type key is always present and the rest depend on the key:
  160. +-----------+-----------------------------------+
  161. | Key | Value |
  162. +===========+===================================+
  163. | type | (str) "C", "R", or "O" |
  164. +-----------+-----------------------------------+
  165. | others | Depend on ``type`` |
  166. +-----------+-----------------------------------+
  167. * ``paths`` (list): A path is described by a line an aperture that follows that
  168. line. Each paths[i] is a dictionary:
  169. +------------+------------------------------------------------+
  170. | Key | Value |
  171. +============+================================================+
  172. | linestring | (Shapely.LineString) The actual path. |
  173. +------------+------------------------------------------------+
  174. | aperture | (str) The key for an aperture in apertures. |
  175. +------------+------------------------------------------------+
  176. * ``flashes`` (list): Flashes are single-point strokes of an aperture. Each
  177. is a dictionary:
  178. +------------+------------------------------------------------+
  179. | Key | Value |
  180. +============+================================================+
  181. | loc | (list) [x (float), y (float)] coordinates. |
  182. +------------+------------------------------------------------+
  183. | aperture | (str) The key for an aperture in apertures. |
  184. +------------+------------------------------------------------+
  185. * ``regions`` (list): Are surfaces defined by a polygon (Shapely.Polygon),
  186. which have an exterior and zero or more interiors. An aperture is also
  187. associated with a region. Each is a dictionary:
  188. +------------+-----------------------------------------------------+
  189. | Key | Value |
  190. +============+=====================================================+
  191. | polygon | (Shapely.Polygon) The polygon defining the region. |
  192. +------------+-----------------------------------------------------+
  193. | aperture | (str) The key for an aperture in apertures. |
  194. +------------+-----------------------------------------------------+
  195. * ``flash_geometry`` (list): List of (Shapely) geometric object resulting
  196. from ``flashes``. These are generated from ``flashes`` in ``do_flashes()``.
  197. * ``buffered_paths`` (list): List of (Shapely) polygons resulting from
  198. *buffering* (or thickening) the ``paths`` with the aperture. These are
  199. generated from ``paths`` in ``buffer_paths()``.
  200. **USAGE**::
  201. g = Gerber()
  202. g.parse_file(filename)
  203. g.create_geometry()
  204. do_something(s.solid_geometry)
  205. """
  206. def __init__(self):
  207. """
  208. The constructor takes no parameters. Use ``gerber.parse_files()``
  209. or ``gerber.parse_lines()`` to populate the object from Gerber source.
  210. :return: Gerber object
  211. :rtype: Gerber
  212. """
  213. # Initialize parent
  214. Geometry.__init__(self)
  215. # Number format
  216. self.int_digits = 3
  217. """Number of integer digits in Gerber numbers. Used during parsing."""
  218. self.frac_digits = 4
  219. """Number of fraction digits in Gerber numbers. Used during parsing."""
  220. ## Gerber elements ##
  221. # Apertures {'id':{'type':chr,
  222. # ['size':float], ['width':float],
  223. # ['height':float]}, ...}
  224. self.apertures = {}
  225. # Paths [{'linestring':LineString, 'aperture':str}]
  226. self.paths = []
  227. # Buffered Paths [Polygon]
  228. # Paths transformed into Polygons by
  229. # offsetting the aperture size/2
  230. self.buffered_paths = []
  231. # Polygon regions [{'polygon':Polygon, 'aperture':str}]
  232. self.regions = []
  233. # Flashes [{'loc':[float,float], 'aperture':str}]
  234. self.flashes = []
  235. # Geometry from flashes
  236. self.flash_geometry = []
  237. # Attributes to be included in serialization
  238. # Always append to it because it carries contents
  239. # from Geometry.
  240. self.ser_attrs += ['int_digits', 'frac_digits', 'apertures', 'paths',
  241. 'buffered_paths', 'regions', 'flashes',
  242. 'flash_geometry']
  243. #### Parser patterns ####
  244. # FS - Format Specification
  245. # The format of X and Y must be the same!
  246. # L-omit leading zeros, T-omit trailing zeros
  247. # A-absolute notation, I-incremental notation
  248. self.fmt_re = re.compile(r'%FS([LT])([AI])X(\d)(\d)Y\d\d\*%$')
  249. # Mode (IN/MM)
  250. self.mode_re = re.compile(r'^%MO(IN|MM)\*%$')
  251. # Comment G04|G4
  252. self.comm_re = re.compile(r'^G0?4(.*)$')
  253. # AD - Aperture definition
  254. self.ad_re = re.compile(r'^%ADD(\d\d+)([a-zA-Z0-9]*),(.*)\*%$')
  255. # AM - Aperture Macro
  256. # Beginning of macro (Ends with *%):
  257. self.am_re = re.compile(r'^%AM([a-zA-Z0-9]*)\*')
  258. # Tool change
  259. # May begin with G54 but that is deprecated
  260. self.tool_re = re.compile(r'^(?:G54)?D(\d\d+)\*$')
  261. # G01 - Linear interpolation plus flashes
  262. # Operation code (D0x) missing is deprecated... oh well I will support it.
  263. self.lin_re = re.compile(r'^(?:G0?(1))?(?:X(-?\d+))?(?:Y(-?\d+))?(?:D0([123]))?\*$')
  264. self.setlin_re = re.compile(r'^(?:G0?1)\*')
  265. # G02/3 - Circular interpolation
  266. # 2-clockwise, 3-counterclockwise
  267. self.circ_re = re.compile(r'^(?:G0?([23]))?(?:X(-?\d+))?(?:Y(-?\d+))' +
  268. '?(?:I(-?\d+))?(?:J(-?\d+))?D0([12])\*$')
  269. # G01/2/3 Occurring without coordinates
  270. self.interp_re = re.compile(r'^(?:G0?([123]))\*')
  271. # Single D74 or multi D75 quadrant for circular interpolation
  272. self.quad_re = re.compile(r'^G7([45])\*$')
  273. # Region mode on
  274. # In region mode, D01 starts a region
  275. # and D02 ends it. A new region can be started again
  276. # with D01. All contours must be closed before
  277. # D02 or G37.
  278. self.regionon_re = re.compile(r'^G36\*$')
  279. # Region mode off
  280. # Will end a region and come off region mode.
  281. # All contours must be closed before D02 or G37.
  282. self.regionoff_re = re.compile(r'^G37\*$')
  283. # End of file
  284. self.eof_re = re.compile(r'^M02\*')
  285. # IP - Image polarity
  286. self.pol_re = re.compile(r'^%IP(POS|NEG)\*%$')
  287. # LP - Level polarity
  288. self.lpol_re = re.compile(r'^%LP([DC])\*%$')
  289. # TODO: This is bad.
  290. self.steps_per_circ = 40
  291. def scale(self, factor):
  292. """
  293. Scales the objects' geometry on the XY plane by a given factor.
  294. These are:
  295. * ``apertures``
  296. * ``paths``
  297. * ``regions``
  298. * ``flashes``
  299. Then ``buffered_paths``, ``flash_geometry`` and ``solid_geometry``
  300. are re-created with ``self.create_geometry()``.
  301. :param factor: Number by which to scale.
  302. :type factor: float
  303. :rtype : None
  304. """
  305. # Apertures
  306. #print "Scaling apertures..."
  307. #List of the non-dimension aperture parameters
  308. nonDimensions = ["type", "nVertices", "rotation"]
  309. for apid in self.apertures:
  310. for param in self.apertures[apid]:
  311. if param not in nonDimensions: # All others are dimensions.
  312. print "Tool:", apid, "Parameter:", param
  313. self.apertures[apid][param] *= factor
  314. # Paths
  315. #print "Scaling paths..."
  316. for path in self.paths:
  317. path['linestring'] = affinity.scale(path['linestring'],
  318. factor, factor, origin=(0, 0))
  319. # Flashes
  320. #print "Scaling flashes..."
  321. for fl in self.flashes:
  322. # TODO: Shouldn't 'loc' be a numpy.array()?
  323. fl['loc'][0] *= factor
  324. fl['loc'][1] *= factor
  325. # Regions
  326. #print "Scaling regions..."
  327. for reg in self.regions:
  328. reg['polygon'] = affinity.scale(reg['polygon'], factor, factor,
  329. origin=(0, 0))
  330. # Now buffered_paths, flash_geometry and solid_geometry
  331. self.create_geometry()
  332. def offset(self, vect):
  333. """
  334. Offsets the objects' geometry on the XY plane by a given vector.
  335. These are:
  336. * ``paths``
  337. * ``regions``
  338. * ``flashes``
  339. Then ``buffered_paths``, ``flash_geometry`` and ``solid_geometry``
  340. are re-created with ``self.create_geometry()``.
  341. :param vect: (x, y) offset vector.
  342. :type vect: tuple
  343. :return: None
  344. """
  345. dx, dy = vect
  346. # Paths
  347. #print "Shifting paths..."
  348. for path in self.paths:
  349. path['linestring'] = affinity.translate(path['linestring'],
  350. xoff=dx, yoff=dy)
  351. # Flashes
  352. #print "Shifting flashes..."
  353. for fl in self.flashes:
  354. # TODO: Shouldn't 'loc' be a numpy.array()?
  355. fl['loc'][0] += dx
  356. fl['loc'][1] += dy
  357. # Regions
  358. #print "Shifting regions..."
  359. for reg in self.regions:
  360. reg['polygon'] = affinity.translate(reg['polygon'],
  361. xoff=dx, yoff=dy)
  362. # Now buffered_paths, flash_geometry and solid_geometry
  363. self.create_geometry()
  364. def fix_regions(self):
  365. """
  366. Overwrites the region polygons with fixed
  367. versions if found to be invalid (according to Shapely).
  368. """
  369. for region in self.regions:
  370. if not region['polygon'].is_valid:
  371. region['polygon'] = region['polygon'].buffer(0)
  372. def buffer_paths(self):
  373. """
  374. This is part of the parsing process. "Thickens" the paths
  375. by their appertures. This will only work for circular appertures.
  376. :return: None
  377. """
  378. self.buffered_paths = []
  379. for path in self.paths:
  380. try:
  381. width = self.apertures[path["aperture"]]["size"]
  382. self.buffered_paths.append(path["linestring"].buffer(width/2))
  383. except KeyError:
  384. print "ERROR: Failed to buffer path: ", path
  385. print "Apertures: ", self.apertures
  386. def aperture_parse(self, apertureId, apertureType, apParameters):
  387. """
  388. Parse gerber aperture definition into dictionary of apertures.
  389. The following kinds and their attributes are supported:
  390. * *Circular (C)*: size (float)
  391. * *Rectangle (R)*: width (float), height (float)
  392. * *Obround (O)*: width (float), height (float).
  393. * *Polygon (P)*: diameter(float), vertices(int), [rotation(float)]
  394. :param apertureId: Id of the aperture being defined.
  395. :param apertureType: Type of the aperture.
  396. :param apParameters: Parameters of the aperture.
  397. :type apertureId: str
  398. :type apertureType: str
  399. :type apParameters: str
  400. :return: Identifier of the aperture.
  401. :rtype: str
  402. """
  403. # Found some Gerber with a leading zero in the aperture id and the
  404. # referenced it without the zero, so this is a hack to handle that.
  405. apid = str(int(apertureId))
  406. paramList = apParameters.split('X')
  407. if apertureType == "C" : # Circle, example: %ADD11C,0.1*%
  408. self.apertures[apid] = {"type": "C",
  409. "size": float(paramList[0])}
  410. return apid
  411. if apertureType == "R" : # Rectangle, example: %ADD15R,0.05X0.12*%
  412. self.apertures[apid] = {"type": "R",
  413. "width": float(paramList[0]),
  414. "height": float(paramList[1])}
  415. return apid
  416. if apertureType == "O" : # Obround
  417. self.apertures[apid] = {"type": "O",
  418. "width": float(paramList[0]),
  419. "height": float(paramList[1])}
  420. return apid
  421. if apertureType == "P" :
  422. self.apertures[apid] = {"type": "P",
  423. "diam": float(paramList[0]),
  424. "nVertices": int(paramList[1])}
  425. if len(paramList) >= 3 :
  426. self.apertures[apid]["rotation"] = float(paramList[2])
  427. return apid
  428. print "WARNING: Aperture not implemented:", apertureType
  429. return None
  430. def parse_file(self, filename):
  431. """
  432. Calls Gerber.parse_lines() with array of lines
  433. read from the given file.
  434. """
  435. gfile = open(filename, 'r')
  436. gstr = gfile.readlines()
  437. gfile.close()
  438. self.parse_lines(gstr)
  439. def parse_lines(self, glines):
  440. """
  441. Main Gerber parser. Reads Gerber and populates ``self.paths``, ``self.apertures``,
  442. ``self.flashes``, ``self.regions`` and ``self.units``.
  443. :param glines: Gerber code as list of strings, each element being
  444. one line of the source file.
  445. :type glines: list
  446. :return: None
  447. :rtype: None
  448. """
  449. path = [] # Coordinates of the current path, each is [x, y]
  450. last_path_aperture = None
  451. current_aperture = None
  452. # 1,2 or 3 from "G01", "G02" or "G03"
  453. current_interpolation_mode = None
  454. # 1 or 2 from "D01" or "D02"
  455. # Note this is to support deprecated Gerber not putting
  456. # an operation code at the end of every coordinate line.
  457. current_operation_code = None
  458. # Current coordinates
  459. current_x = None
  460. current_y = None
  461. # How to interprest circular interpolation: SINGLE or MULTI
  462. quadrant_mode = None
  463. line_num = 0
  464. for gline in glines:
  465. line_num += 1
  466. ## G01 - Linear interpolation plus flashes
  467. # Operation code (D0x) missing is deprecated... oh well I will support it.
  468. match = self.lin_re.search(gline)
  469. if match:
  470. # Dxx alone? Will ignore for now.
  471. if match.group(1) is None and match.group(2) is None and match.group(3) is None:
  472. try:
  473. current_operation_code = int(match.group(4))
  474. except:
  475. pass # A line with just * will match too.
  476. continue
  477. # Parse coordinates
  478. if match.group(2) is not None:
  479. current_x = parse_gerber_number(match.group(2), self.frac_digits)
  480. if match.group(3) is not None:
  481. current_y = parse_gerber_number(match.group(3), self.frac_digits)
  482. # Parse operation code
  483. if match.group(4) is not None:
  484. current_operation_code = int(match.group(4))
  485. # Pen down: add segment
  486. if current_operation_code == 1:
  487. path.append([current_x, current_y])
  488. last_path_aperture = current_aperture
  489. # Pen up: finish path
  490. elif current_operation_code == 2:
  491. if len(path) > 1:
  492. if last_path_aperture is None:
  493. print "Warning: No aperture defined for curent path. (%d)" % line_num
  494. self.paths.append({"linestring": LineString(path),
  495. "aperture": last_path_aperture})
  496. path = [[current_x, current_y]] # Start new path
  497. # Flash
  498. elif current_operation_code == 3:
  499. self.flashes.append({"loc": [current_x, current_y],
  500. "aperture": current_aperture})
  501. continue
  502. ## G02/3 - Circular interpolation
  503. # 2-clockwise, 3-counterclockwise
  504. match = self.circ_re.search(gline)
  505. if match:
  506. mode, x, y, i, j, d = match.groups()
  507. try:
  508. x = parse_gerber_number(x, self.frac_digits)
  509. except:
  510. x = current_x
  511. try:
  512. y = parse_gerber_number(y, self.frac_digits)
  513. except:
  514. y = current_y
  515. try:
  516. i = parse_gerber_number(i, self.frac_digits)
  517. except:
  518. i = 0
  519. try:
  520. j = parse_gerber_number(j, self.frac_digits)
  521. except:
  522. j = 0
  523. if quadrant_mode is None:
  524. print "ERROR: Found arc without preceding quadrant specification G74 or G75. (%d)" % line_num
  525. print gline
  526. continue
  527. if mode is None and current_interpolation_mode not in [2, 3]:
  528. print "ERROR: Found arc without circular interpolation mode defined. (%d)" % line_num
  529. print gline
  530. continue
  531. elif mode is not None:
  532. current_interpolation_mode = int(mode)
  533. # Set operation code if provided
  534. if d is not None:
  535. current_operation_code = int(d)
  536. # Nothing created! Pen Up.
  537. if current_operation_code == 2:
  538. print "Warning: Arc with D2. (%d)" % line_num
  539. if len(path) > 1:
  540. if last_path_aperture is None:
  541. print "Warning: No aperture defined for curent path. (%d)" % line_num
  542. self.paths.append({"linestring": LineString(path),
  543. "aperture": last_path_aperture})
  544. current_x = x
  545. current_y = y
  546. path = [[current_x, current_y]] # Start new path
  547. continue
  548. # Flash should not happen here
  549. if current_operation_code == 3:
  550. print "ERROR: Trying to flash within arc. (%d)" % line_num
  551. continue
  552. if quadrant_mode == 'MULTI':
  553. center = [i + current_x, j + current_y]
  554. radius = sqrt(i**2 + j**2)
  555. start = arctan2(-j, -i)
  556. stop = arctan2(-center[1] + y, -center[0] + x)
  557. arcdir = [None, None, "cw", "ccw"]
  558. this_arc = arc(center, radius, start, stop,
  559. arcdir[current_interpolation_mode],
  560. self.steps_per_circ)
  561. # Last point in path is current point
  562. current_x = this_arc[-1][0]
  563. current_y = this_arc[-1][1]
  564. # Append
  565. path += this_arc
  566. last_path_aperture = current_aperture
  567. continue
  568. if quadrant_mode == 'SINGLE':
  569. print "Warning: Single quadrant arc are not implemented yet. (%d)" % line_num
  570. ## G74/75* - Single or multiple quadrant arcs
  571. match = self.quad_re.search(gline)
  572. if match:
  573. if match.group(1) == '4':
  574. quadrant_mode = 'SINGLE'
  575. else:
  576. quadrant_mode = 'MULTI'
  577. continue
  578. ## G37* - End region
  579. if self.regionoff_re.search(gline):
  580. # Only one path defines region?
  581. if len(path) < 3:
  582. print "ERROR: Path contains less than 3 points:"
  583. print path
  584. print "Line (%d): " % line_num, gline
  585. path = []
  586. continue
  587. # For regions we may ignore an aperture that is None
  588. self.regions.append({"polygon": Polygon(path),
  589. "aperture": last_path_aperture})
  590. #path = []
  591. path = [[current_x, current_y]] # Start new path
  592. continue
  593. #Parse an aperture.
  594. match = self.ad_re.search(gline)
  595. if match:
  596. self.aperture_parse(match.group(1),match.group(2),match.group(3))
  597. continue
  598. ## G01/2/3* - Interpolation mode change
  599. # Can occur along with coordinates and operation code but
  600. # sometimes by itself (handled here).
  601. # Example: G01*
  602. match = self.interp_re.search(gline)
  603. if match:
  604. current_interpolation_mode = int(match.group(1))
  605. continue
  606. ## Tool/aperture change
  607. # Example: D12*
  608. match = self.tool_re.search(gline)
  609. if match:
  610. current_aperture = match.group(1)
  611. continue
  612. ## Number format
  613. # Example: %FSLAX24Y24*%
  614. # TODO: This is ignoring most of the format. Implement the rest.
  615. match = self.fmt_re.search(gline)
  616. if match:
  617. self.int_digits = int(match.group(3))
  618. self.frac_digits = int(match.group(4))
  619. continue
  620. ## Mode (IN/MM)
  621. # Example: %MOIN*%
  622. match = self.mode_re.search(gline)
  623. if match:
  624. self.units = match.group(1)
  625. continue
  626. print "WARNING: Line ignored (%d):" % line_num, gline
  627. if len(path) > 1:
  628. # EOF, create shapely LineString if something still in path
  629. self.paths.append({"linestring": LineString(path),
  630. "aperture": last_path_aperture})
  631. def do_flashes(self):
  632. """
  633. Creates geometry for Gerber flashes (aperture on a single point).
  634. """
  635. self.flash_geometry = []
  636. for flash in self.flashes:
  637. try:
  638. aperture = self.apertures[flash['aperture']]
  639. except KeyError:
  640. print "ERROR: Trying to flash with unknown aperture: ", flash['aperture']
  641. continue
  642. if aperture['type'] == 'C': # Circles
  643. circle = Point(flash['loc']).buffer(aperture['size']/2)
  644. self.flash_geometry.append(circle)
  645. continue
  646. if aperture['type'] == 'R': # Rectangles
  647. loc = flash['loc']
  648. width = aperture['width']
  649. height = aperture['height']
  650. minx = loc[0] - width/2
  651. maxx = loc[0] + width/2
  652. miny = loc[1] - height/2
  653. maxy = loc[1] + height/2
  654. rectangle = shply_box(minx, miny, maxx, maxy)
  655. self.flash_geometry.append(rectangle)
  656. continue
  657. if aperture['type'] == 'O': # Obround
  658. loc = flash['loc']
  659. width = aperture['width']
  660. height = aperture['height']
  661. if width > height:
  662. p1 = Point(loc[0] + 0.5*(width-height), loc[1])
  663. p2 = Point(loc[0] - 0.5*(width-height), loc[1])
  664. c1 = p1.buffer(height*0.5)
  665. c2 = p2.buffer(height*0.5)
  666. else:
  667. p1 = Point(loc[0], loc[1] + 0.5*(height-width))
  668. p2 = Point(loc[0], loc[1] - 0.5*(height-width))
  669. c1 = p1.buffer(width*0.5)
  670. c2 = p2.buffer(width*0.5)
  671. obround = cascaded_union([c1, c2]).convex_hull
  672. self.flash_geometry.append(obround)
  673. continue
  674. if aperture['type'] == 'P': # Regular polygon
  675. loc = flash['loc']
  676. diam = aperture['diam']
  677. nVertices = aperture['nVertices']
  678. points = []
  679. for i in range(0, nVertices):
  680. x = loc[0] + diam * (cos(2 * pi * i / nVertices))
  681. y = loc[1] + diam * (sin(2 * pi * i / nVertices))
  682. points.append((x, y))
  683. ply = Polygon(points)
  684. if 'rotation' in aperture:
  685. ply = affinity.rotate(ply, aperture['rotation'])
  686. self.flash_geometry.append(ply)
  687. continue
  688. print "WARNING: Aperture type %s not implemented" % (aperture['type'])
  689. def create_geometry(self):
  690. """
  691. Geometry from a Gerber file is made up entirely of polygons.
  692. Every stroke (linear or circular) has an aperture which gives
  693. it thickness. Additionally, aperture strokes have non-zero area,
  694. and regions naturally do as well.
  695. :rtype : None
  696. :return: None
  697. """
  698. self.buffer_paths()
  699. self.fix_regions()
  700. self.do_flashes()
  701. self.solid_geometry = cascaded_union(self.buffered_paths +
  702. [poly['polygon'] for poly in self.regions] +
  703. self.flash_geometry)
  704. def get_bounding_box(self, margin=0.0, rounded=False):
  705. """
  706. Creates and returns a rectangular polygon bounding at a distance of
  707. margin from the object's ``solid_geometry``. If margin > 0, the polygon
  708. can optionally have rounded corners of radius equal to margin.
  709. :param margin: Distance to enlarge the rectangular bounding
  710. box in both positive and negative, x and y axes.
  711. :type margin: float
  712. :param rounded: Wether or not to have rounded corners.
  713. :type rounded: bool
  714. :return: The bounding box.
  715. :rtype: Shapely.Polygon
  716. """
  717. bbox = self.solid_geometry.envelope.buffer(margin)
  718. if not rounded:
  719. bbox = bbox.envelope
  720. return bbox
  721. class Excellon(Geometry):
  722. """
  723. *ATTRIBUTES*
  724. * ``tools`` (dict): The key is the tool name and the value is
  725. the size (diameter).
  726. * ``drills`` (list): Each is a dictionary:
  727. ================ ====================================
  728. Key Value
  729. ================ ====================================
  730. point (Shapely.Point) Where to drill
  731. tool (str) A key in ``tools``
  732. ================ ====================================
  733. """
  734. def __init__(self):
  735. """
  736. The constructor takes no parameters.
  737. :return: Excellon object.
  738. :rtype: Excellon
  739. """
  740. Geometry.__init__(self)
  741. self.tools = {}
  742. self.drills = []
  743. # Trailing "T" or leading "L"
  744. self.zeros = ""
  745. # Attributes to be included in serialization
  746. # Always append to it because it carries contents
  747. # from Geometry.
  748. self.ser_attrs += ['tools', 'drills', 'zeros']
  749. #### Patterns ####
  750. # Regex basics:
  751. # ^ - beginning
  752. # $ - end
  753. # *: 0 or more, +: 1 or more, ?: 0 or 1
  754. # M48 - Beggining of Part Program Header
  755. self.hbegin_re = re.compile(r'^M48$')
  756. # M95 or % - End of Part Program Header
  757. # NOTE: % has different meaning in the body
  758. self.hend_re = re.compile(r'^(?:M95|%)$')
  759. # FMAT Excellon format
  760. self.fmat_re = re.compile(r'^FMAT,([12])$')
  761. # Number format and units
  762. # INCH uses 6 digits
  763. # METRIC uses 5/6
  764. self.units_re = re.compile(r'^(INCH|METRIC)(?:,([TL])Z)?$')
  765. # Tool definition/parameters (?= is look-ahead
  766. # NOTE: This might be an overkill!
  767. self.toolset_re = re.compile(r'^T(0?\d|\d\d)(?=.*C(\d*\.?\d*))?' +
  768. r'(?=.*F(\d*\.?\d*))?(?=.*S(\d*\.?\d*))?' +
  769. r'(?=.*B(\d*\.?\d*))?(?=.*H(\d*\.?\d*))?' +
  770. r'(?=.*Z(-?\d*\.?\d*))?[CFSBHT]')
  771. # Tool select
  772. # Can have additional data after tool number but
  773. # is ignored if present in the header.
  774. # Warning: This will match toolset_re too.
  775. self.toolsel_re = re.compile(r'^T((?:\d\d)|(?:\d))')
  776. # Comment
  777. self.comm_re = re.compile(r'^;(.*)$')
  778. # Absolute/Incremental G90/G91
  779. self.absinc_re = re.compile(r'^G9([01])$')
  780. # Modes of operation
  781. # 1-linear, 2-circCW, 3-cirCCW, 4-vardwell, 5-Drill
  782. self.modes_re = re.compile(r'^G0([012345])')
  783. # Measuring mode
  784. # 1-metric, 2-inch
  785. self.meas_re = re.compile(r'^M7([12])$')
  786. # Coordinates
  787. #self.xcoord_re = re.compile(r'^X(\d*\.?\d*)(?:Y\d*\.?\d*)?$')
  788. #self.ycoord_re = re.compile(r'^(?:X\d*\.?\d*)?Y(\d*\.?\d*)$')
  789. self.coordsperiod_re = re.compile(r'(?=.*X(\d*\.\d*))?(?=.*Y(\d*\.\d*))?[XY]')
  790. self.coordsnoperiod_re = re.compile(r'(?!.*\.)(?=.*X(\d*))?(?=.*Y(\d*))?[XY]')
  791. # R - Repeat hole (# times, X offset, Y offset)
  792. self.rep_re = re.compile(r'^R(\d+)(?=.*[XY])+(?:X(\d*\.?\d*))?(?:Y(\d*\.?\d*))?$')
  793. # Various stop/pause commands
  794. self.stop_re = re.compile(r'^((G04)|(M09)|(M06)|(M00)|(M30))')
  795. def parse_file(self, filename):
  796. """
  797. Reads the specified file as array of lines as
  798. passes it to ``parse_lines()``.
  799. :param filename: The file to be read and parsed.
  800. :type filename: str
  801. :return: None
  802. """
  803. efile = open(filename, 'r')
  804. estr = efile.readlines()
  805. efile.close()
  806. self.parse_lines(estr)
  807. def parse_lines(self, elines):
  808. """
  809. Main Excellon parser.
  810. :param elines: List of strings, each being a line of Excellon code.
  811. :type elines: list
  812. :return: None
  813. """
  814. # State variables
  815. current_tool = ""
  816. in_header = False
  817. current_x = None
  818. current_y = None
  819. i = 0 # Line number
  820. for eline in elines:
  821. i += 1
  822. ## Header Begin/End ##
  823. if self.hbegin_re.search(eline):
  824. in_header = True
  825. continue
  826. if self.hend_re.search(eline):
  827. in_header = False
  828. continue
  829. #### Body ####
  830. if not in_header:
  831. ## Tool change ##
  832. match = self.toolsel_re.search(eline)
  833. if match:
  834. current_tool = str(int(match.group(1)))
  835. continue
  836. ## Coordinates without period ##
  837. match = self.coordsnoperiod_re.search(eline)
  838. if match:
  839. try:
  840. x = float(match.group(1))/10000
  841. current_x = x
  842. except TypeError:
  843. x = current_x
  844. try:
  845. y = float(match.group(2))/10000
  846. current_y = y
  847. except TypeError:
  848. y = current_y
  849. if x is None or y is None:
  850. print "ERROR: Missing coordinates"
  851. continue
  852. self.drills.append({'point': Point((x, y)), 'tool': current_tool})
  853. continue
  854. ## Coordinates with period ##
  855. match = self.coordsperiod_re.search(eline)
  856. if match:
  857. try:
  858. x = float(match.group(1))
  859. current_x = x
  860. except TypeError:
  861. x = current_x
  862. try:
  863. y = float(match.group(2))
  864. current_y = y
  865. except TypeError:
  866. y = current_y
  867. if x is None or y is None:
  868. print "ERROR: Missing coordinates"
  869. continue
  870. self.drills.append({'point': Point((x, y)), 'tool': current_tool})
  871. continue
  872. #### Header ####
  873. if in_header:
  874. ## Tool definitions ##
  875. match = self.toolset_re.search(eline)
  876. if match:
  877. name = str(int(match.group(1)))
  878. spec = {
  879. "C": float(match.group(2)),
  880. # "F": float(match.group(3)),
  881. # "S": float(match.group(4)),
  882. # "B": float(match.group(5)),
  883. # "H": float(match.group(6)),
  884. # "Z": float(match.group(7))
  885. }
  886. self.tools[name] = spec
  887. continue
  888. ## Units and number format ##
  889. match = self.units_re.match(eline)
  890. if match:
  891. self.zeros = match.group(2) # "T" or "L"
  892. self.units = {"INCH": "IN", "METRIC": "MM"}[match.group(1)]
  893. continue
  894. print "WARNING: Line ignored:", eline
  895. def create_geometry(self):
  896. self.solid_geometry = []
  897. for drill in self.drills:
  898. poly = Point(drill['point']).buffer(self.tools[drill['tool']]["C"]/2.0)
  899. self.solid_geometry.append(poly)
  900. #self.solid_geometry = cascaded_union(self.solid_geometry)
  901. def scale(self, factor):
  902. """
  903. Scales geometry on the XY plane in the object by a given factor.
  904. Tool sizes, feedrates an Z-plane dimensions are untouched.
  905. :param factor: Number by which to scale the object.
  906. :type factor: float
  907. :return: None
  908. :rtype: NOne
  909. """
  910. # Drills
  911. for drill in self.drills:
  912. drill['point'] = affinity.scale(drill['point'], factor, factor, origin=(0, 0))
  913. self.create_geometry()
  914. def offset(self, vect):
  915. """
  916. Offsets geometry on the XY plane in the object by a given vector.
  917. :param vect: (x, y) offset vector.
  918. :type vect: tuple
  919. :return: None
  920. """
  921. dx, dy = vect
  922. # Drills
  923. for drill in self.drills:
  924. drill['point'] = affinity.translate(drill['point'], xoff=dx, yoff=dy)
  925. self.create_geometry()
  926. def convert_units(self, units):
  927. factor = Geometry.convert_units(self, units)
  928. # Tools
  929. for tname in self.tools:
  930. self.tools[tname]["C"] *= factor
  931. self.create_geometry()
  932. return factor
  933. class CNCjob(Geometry):
  934. """
  935. Represents work to be done by a CNC machine.
  936. *ATTRIBUTES*
  937. * ``gcode_parsed`` (list): Each is a dictionary:
  938. ===================== =========================================
  939. Key Value
  940. ===================== =========================================
  941. geom (Shapely.LineString) Tool path (XY plane)
  942. kind (string) "AB", A is "T" (travel) or
  943. "C" (cut). B is "F" (fast) or "S" (slow).
  944. ===================== =========================================
  945. """
  946. def __init__(self, units="in", kind="generic", z_move=0.1,
  947. feedrate=3.0, z_cut=-0.002, tooldia=0.0):
  948. Geometry.__init__(self)
  949. self.kind = kind
  950. self.units = units
  951. self.z_cut = z_cut
  952. self.z_move = z_move
  953. self.feedrate = feedrate
  954. self.tooldia = tooldia
  955. self.unitcode = {"IN": "G20", "MM": "G21"}
  956. self.pausecode = "G04 P1"
  957. self.feedminutecode = "G94"
  958. self.absolutecode = "G90"
  959. self.gcode = ""
  960. self.input_geometry_bounds = None
  961. self.gcode_parsed = None
  962. self.steps_per_circ = 20 # Used when parsing G-code arcs
  963. # Attributes to be included in serialization
  964. # Always append to it because it carries contents
  965. # from Geometry.
  966. self.ser_attrs += ['kind', 'z_cut', 'z_move', 'feedrate', 'tooldia',
  967. 'gcode', 'input_geometry_bounds', 'gcode_parsed',
  968. 'steps_per_circ']
  969. def convert_units(self, units):
  970. factor = Geometry.convert_units(self, units)
  971. print "CNCjob.convert_units()"
  972. self.z_cut *= factor
  973. self.z_move *= factor
  974. self.feedrate *= factor
  975. self.tooldia *= factor
  976. return factor
  977. def generate_from_excellon(self, exobj):
  978. """
  979. Generates G-code for drilling from Excellon object.
  980. self.gcode becomes a list, each element is a
  981. different job for each tool in the excellon code.
  982. """
  983. self.kind = "drill"
  984. self.gcode = []
  985. t = "G00 X%.4fY%.4f\n"
  986. down = "G01 Z%.4f\n" % self.z_cut
  987. up = "G01 Z%.4f\n" % self.z_move
  988. for tool in exobj.tools:
  989. points = []
  990. for drill in exobj.drill:
  991. if drill['tool'] == tool:
  992. points.append(drill['point'])
  993. gcode = self.unitcode[self.units.upper()] + "\n"
  994. gcode += self.absolutecode + "\n"
  995. gcode += self.feedminutecode + "\n"
  996. gcode += "F%.2f\n" % self.feedrate
  997. gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  998. gcode += "M03\n" # Spindle start
  999. gcode += self.pausecode + "\n"
  1000. for point in points:
  1001. gcode += t % point
  1002. gcode += down + up
  1003. gcode += t % (0, 0)
  1004. gcode += "M05\n" # Spindle stop
  1005. self.gcode.append(gcode)
  1006. def generate_from_excellon_by_tool(self, exobj, tools="all"):
  1007. """
  1008. Creates gcode for this object from an Excellon object
  1009. for the specified tools.
  1010. :param exobj: Excellon object to process
  1011. :type exobj: Excellon
  1012. :param tools: Comma separated tool names
  1013. :type: tools: str
  1014. :return: None
  1015. :rtype: None
  1016. """
  1017. print "Creating CNC Job from Excellon..."
  1018. if tools == "all":
  1019. tools = [tool for tool in exobj.tools]
  1020. else:
  1021. tools = [x.strip() for x in tools.split(",")]
  1022. tools = filter(lambda y: y in exobj.tools, tools)
  1023. print "Tools are:", tools
  1024. points = []
  1025. for drill in exobj.drills:
  1026. if drill['tool'] in tools:
  1027. points.append(drill['point'])
  1028. print "Found %d drills." % len(points)
  1029. #self.kind = "drill"
  1030. self.gcode = []
  1031. t = "G00 X%.4fY%.4f\n"
  1032. down = "G01 Z%.4f\n" % self.z_cut
  1033. up = "G01 Z%.4f\n" % self.z_move
  1034. gcode = self.unitcode[self.units.upper()] + "\n"
  1035. gcode += self.absolutecode + "\n"
  1036. gcode += self.feedminutecode + "\n"
  1037. gcode += "F%.2f\n" % self.feedrate
  1038. gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  1039. gcode += "M03\n" # Spindle start
  1040. gcode += self.pausecode + "\n"
  1041. for point in points:
  1042. x, y = point.coords.xy
  1043. gcode += t % (x[0], y[0])
  1044. gcode += down + up
  1045. gcode += t % (0, 0)
  1046. gcode += "M05\n" # Spindle stop
  1047. self.gcode = gcode
  1048. def generate_from_geometry(self, geometry, append=True, tooldia=None, tolerance=0):
  1049. """
  1050. Generates G-Code from a Geometry object. Stores in ``self.gcode``.
  1051. :param geometry: Geometry defining the toolpath
  1052. :type geometry: Geometry
  1053. :param append: Wether to append to self.gcode or re-write it.
  1054. :type append: bool
  1055. :param tooldia: If given, sets the tooldia property but does
  1056. not affect the process in any other way.
  1057. :type tooldia: bool
  1058. :param tolerance: All points in the simplified object will be within the
  1059. tolerance distance of the original geometry.
  1060. :return: None
  1061. :rtype: None
  1062. """
  1063. if tooldia is not None:
  1064. self.tooldia = tooldia
  1065. self.input_geometry_bounds = geometry.bounds()
  1066. if not append:
  1067. self.gcode = ""
  1068. self.gcode = self.unitcode[self.units.upper()] + "\n"
  1069. self.gcode += self.absolutecode + "\n"
  1070. self.gcode += self.feedminutecode + "\n"
  1071. self.gcode += "F%.2f\n" % self.feedrate
  1072. self.gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  1073. self.gcode += "M03\n" # Spindle start
  1074. self.gcode += self.pausecode + "\n"
  1075. for geo in geometry.solid_geometry:
  1076. if type(geo) == Polygon:
  1077. self.gcode += self.polygon2gcode(geo, tolerance=tolerance)
  1078. continue
  1079. if type(geo) == LineString or type(geo) == LinearRing:
  1080. self.gcode += self.linear2gcode(geo, tolerance=tolerance)
  1081. continue
  1082. if type(geo) == Point:
  1083. self.gcode += self.point2gcode(geo)
  1084. continue
  1085. if type(geo) == MultiPolygon:
  1086. for poly in geo:
  1087. self.gcode += self.polygon2gcode(poly, tolerance=tolerance)
  1088. continue
  1089. print "WARNING: G-code generation not implemented for %s" % (str(type(geo)))
  1090. self.gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1091. self.gcode += "G00 X0Y0\n"
  1092. self.gcode += "M05\n" # Spindle stop
  1093. def pre_parse(self, gtext):
  1094. """
  1095. Separates parts of the G-Code text into a list of dictionaries.
  1096. Used by ``self.gcode_parse()``.
  1097. :param gtext: A single string with g-code
  1098. """
  1099. # Units: G20-inches, G21-mm
  1100. units_re = re.compile(r'^G2([01])')
  1101. # TODO: This has to be re-done
  1102. gcmds = []
  1103. lines = gtext.split("\n") # TODO: This is probably a lot of work!
  1104. for line in lines:
  1105. # Clean up
  1106. line = line.strip()
  1107. # Remove comments
  1108. # NOTE: Limited to 1 bracket pair
  1109. op = line.find("(")
  1110. cl = line.find(")")
  1111. if op > -1 and cl > op:
  1112. #comment = line[op+1:cl]
  1113. line = line[:op] + line[(cl+1):]
  1114. # Units
  1115. match = units_re.match(line)
  1116. if match:
  1117. self.units = {'0': "IN", '1': "MM"}[match.group(1)]
  1118. # Parse GCode
  1119. # 0 4 12
  1120. # G01 X-0.007 Y-0.057
  1121. # --> codes_idx = [0, 4, 12]
  1122. codes = "NMGXYZIJFP"
  1123. codes_idx = []
  1124. i = 0
  1125. for ch in line:
  1126. if ch in codes:
  1127. codes_idx.append(i)
  1128. i += 1
  1129. n_codes = len(codes_idx)
  1130. if n_codes == 0:
  1131. continue
  1132. # Separate codes in line
  1133. parts = []
  1134. for p in range(n_codes-1):
  1135. parts.append(line[codes_idx[p]:codes_idx[p+1]].strip())
  1136. parts.append(line[codes_idx[-1]:].strip())
  1137. # Separate codes from values
  1138. cmds = {}
  1139. for part in parts:
  1140. cmds[part[0]] = float(part[1:])
  1141. gcmds.append(cmds)
  1142. return gcmds
  1143. def gcode_parse(self):
  1144. """
  1145. G-Code parser (from self.gcode). Generates dictionary with
  1146. single-segment LineString's and "kind" indicating cut or travel,
  1147. fast or feedrate speed.
  1148. """
  1149. kind = ["C", "F"] # T=travel, C=cut, F=fast, S=slow
  1150. # Results go here
  1151. geometry = []
  1152. # TODO: Merge into single parser?
  1153. gobjs = self.pre_parse(self.gcode)
  1154. # Last known instruction
  1155. current = {'X': 0.0, 'Y': 0.0, 'Z': 0.0, 'G': 0}
  1156. # Current path: temporary storage until tool is
  1157. # lifted or lowered.
  1158. path = [(0, 0)]
  1159. # Process every instruction
  1160. for gobj in gobjs:
  1161. ## Changing height
  1162. if 'Z' in gobj:
  1163. if ('X' in gobj or 'Y' in gobj) and gobj['Z'] != current['Z']:
  1164. print "WARNING: Non-orthogonal motion: From", current
  1165. print " To:", gobj
  1166. current['Z'] = gobj['Z']
  1167. # Store the path into geometry and reset path
  1168. if len(path) > 1:
  1169. geometry.append({"geom": LineString(path),
  1170. "kind": kind})
  1171. path = [path[-1]] # Start with the last point of last path.
  1172. if 'G' in gobj:
  1173. current['G'] = int(gobj['G'])
  1174. if 'X' in gobj or 'Y' in gobj:
  1175. if 'X' in gobj:
  1176. x = gobj['X']
  1177. else:
  1178. x = current['X']
  1179. if 'Y' in gobj:
  1180. y = gobj['Y']
  1181. else:
  1182. y = current['Y']
  1183. kind = ["C", "F"] # T=travel, C=cut, F=fast, S=slow
  1184. if current['Z'] > 0:
  1185. kind[0] = 'T'
  1186. if current['G'] > 0:
  1187. kind[1] = 'S'
  1188. arcdir = [None, None, "cw", "ccw"]
  1189. if current['G'] in [0, 1]: # line
  1190. path.append((x, y))
  1191. if current['G'] in [2, 3]: # arc
  1192. center = [gobj['I'] + current['X'], gobj['J'] + current['Y']]
  1193. radius = sqrt(gobj['I']**2 + gobj['J']**2)
  1194. start = arctan2(-gobj['J'], -gobj['I'])
  1195. stop = arctan2(-center[1]+y, -center[0]+x)
  1196. path += arc(center, radius, start, stop,
  1197. arcdir[current['G']],
  1198. self.steps_per_circ)
  1199. # Update current instruction
  1200. for code in gobj:
  1201. current[code] = gobj[code]
  1202. # There might not be a change in height at the
  1203. # end, therefore, see here too if there is
  1204. # a final path.
  1205. if len(path) > 1:
  1206. geometry.append({"geom": LineString(path),
  1207. "kind": kind})
  1208. self.gcode_parsed = geometry
  1209. return geometry
  1210. # def plot(self, tooldia=None, dpi=75, margin=0.1,
  1211. # color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]},
  1212. # alpha={"T": 0.3, "C": 1.0}):
  1213. # """
  1214. # Creates a Matplotlib figure with a plot of the
  1215. # G-code job.
  1216. # """
  1217. # if tooldia is None:
  1218. # tooldia = self.tooldia
  1219. #
  1220. # fig = Figure(dpi=dpi)
  1221. # ax = fig.add_subplot(111)
  1222. # ax.set_aspect(1)
  1223. # xmin, ymin, xmax, ymax = self.input_geometry_bounds
  1224. # ax.set_xlim(xmin-margin, xmax+margin)
  1225. # ax.set_ylim(ymin-margin, ymax+margin)
  1226. #
  1227. # if tooldia == 0:
  1228. # for geo in self.gcode_parsed:
  1229. # linespec = '--'
  1230. # linecolor = color[geo['kind'][0]][1]
  1231. # if geo['kind'][0] == 'C':
  1232. # linespec = 'k-'
  1233. # x, y = geo['geom'].coords.xy
  1234. # ax.plot(x, y, linespec, color=linecolor)
  1235. # else:
  1236. # for geo in self.gcode_parsed:
  1237. # poly = geo['geom'].buffer(tooldia/2.0)
  1238. # patch = PolygonPatch(poly, facecolor=color[geo['kind'][0]][0],
  1239. # edgecolor=color[geo['kind'][0]][1],
  1240. # alpha=alpha[geo['kind'][0]], zorder=2)
  1241. # ax.add_patch(patch)
  1242. #
  1243. # return fig
  1244. def plot2(self, axes, tooldia=None, dpi=75, margin=0.1,
  1245. color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]},
  1246. alpha={"T": 0.3, "C": 1.0}, tool_tolerance=0.0005):
  1247. """
  1248. Plots the G-code job onto the given axes.
  1249. :param axes: Matplotlib axes on which to plot.
  1250. :param tooldia: Tool diameter.
  1251. :param dpi: Not used!
  1252. :param margin: Not used!
  1253. :param color: Color specification.
  1254. :param alpha: Transparency specification.
  1255. :param tool_tolerance: Tolerance when drawing the toolshape.
  1256. :return: None
  1257. """
  1258. if tooldia is None:
  1259. tooldia = self.tooldia
  1260. if tooldia == 0:
  1261. for geo in self.gcode_parsed:
  1262. linespec = '--'
  1263. linecolor = color[geo['kind'][0]][1]
  1264. if geo['kind'][0] == 'C':
  1265. linespec = 'k-'
  1266. x, y = geo['geom'].coords.xy
  1267. axes.plot(x, y, linespec, color=linecolor)
  1268. else:
  1269. for geo in self.gcode_parsed:
  1270. poly = geo['geom'].buffer(tooldia/2.0).simplify(tool_tolerance)
  1271. patch = PolygonPatch(poly, facecolor=color[geo['kind'][0]][0],
  1272. edgecolor=color[geo['kind'][0]][1],
  1273. alpha=alpha[geo['kind'][0]], zorder=2)
  1274. axes.add_patch(patch)
  1275. def create_geometry(self):
  1276. # TODO: This takes forever. Too much data?
  1277. self.solid_geometry = cascaded_union([geo['geom'] for geo in self.gcode_parsed])
  1278. def polygon2gcode(self, polygon, tolerance=0):
  1279. """
  1280. Creates G-Code for the exterior and all interior paths
  1281. of a polygon.
  1282. :param polygon: A Shapely.Polygon
  1283. :type polygon: Shapely.Polygon
  1284. :param tolerance: All points in the simplified object will be within the
  1285. tolerance distance of the original geometry.
  1286. :type tolerance: float
  1287. :return: G-code to cut along polygon.
  1288. :rtype: str
  1289. """
  1290. if tolerance > 0:
  1291. target_polygon = polygon.simplify(tolerance)
  1292. else:
  1293. target_polygon = polygon
  1294. gcode = ""
  1295. t = "G0%d X%.4fY%.4f\n"
  1296. path = list(target_polygon.exterior.coords) # Polygon exterior
  1297. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1298. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1299. for pt in path[1:]:
  1300. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  1301. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1302. for ints in target_polygon.interiors: # Polygon interiors
  1303. path = list(ints.coords)
  1304. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1305. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1306. for pt in path[1:]:
  1307. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  1308. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1309. return gcode
  1310. def linear2gcode(self, linear, tolerance=0):
  1311. """
  1312. Generates G-code to cut along the linear feature.
  1313. :param linear: The path to cut along.
  1314. :type: Shapely.LinearRing or Shapely.Linear String
  1315. :param tolerance: All points in the simplified object will be within the
  1316. tolerance distance of the original geometry.
  1317. :type tolerance: float
  1318. :return: G-code to cut alon the linear feature.
  1319. :rtype: str
  1320. """
  1321. if tolerance > 0:
  1322. target_linear = linear.simplify(tolerance)
  1323. else:
  1324. target_linear = linear
  1325. gcode = ""
  1326. t = "G0%d X%.4fY%.4f\n"
  1327. path = list(target_linear.coords)
  1328. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1329. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1330. for pt in path[1:]:
  1331. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  1332. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1333. return gcode
  1334. def point2gcode(self, point):
  1335. # TODO: This is not doing anything.
  1336. gcode = ""
  1337. t = "G0%d X%.4fY%.4f\n"
  1338. path = list(point.coords)
  1339. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  1340. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  1341. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  1342. def scale(self, factor):
  1343. """
  1344. Scales all the geometry on the XY plane in the object by the
  1345. given factor. Tool sizes, feedrates, or Z-axis dimensions are
  1346. not altered.
  1347. :param factor: Number by which to scale the object.
  1348. :type factor: float
  1349. :return: None
  1350. :rtype: None
  1351. """
  1352. for g in self.gcode_parsed:
  1353. g['geom'] = affinity.scale(g['geom'], factor, factor, origin=(0, 0))
  1354. self.create_geometry()
  1355. def offset(self, vect):
  1356. """
  1357. Offsets all the geometry on the XY plane in the object by the
  1358. given vector.
  1359. :param vect: (x, y) offset vector.
  1360. :type vect: tuple
  1361. :return: None
  1362. """
  1363. dx, dy = vect
  1364. for g in self.gcode_parsed:
  1365. g['geom'] = affinity.translate(g['geom'], xoff=dx, yoff=dy)
  1366. self.create_geometry()
  1367. def get_bounds(geometry_set):
  1368. xmin = Inf
  1369. ymin = Inf
  1370. xmax = -Inf
  1371. ymax = -Inf
  1372. #print "Getting bounds of:", str(geometry_set)
  1373. for gs in geometry_set:
  1374. try:
  1375. gxmin, gymin, gxmax, gymax = geometry_set[gs].bounds()
  1376. xmin = min([xmin, gxmin])
  1377. ymin = min([ymin, gymin])
  1378. xmax = max([xmax, gxmax])
  1379. ymax = max([ymax, gymax])
  1380. except:
  1381. print "DEV WARNING: Tried to get bounds of empty geometry."
  1382. return [xmin, ymin, xmax, ymax]
  1383. def arc(center, radius, start, stop, direction, steps_per_circ):
  1384. """
  1385. Creates a list of point along the specified arc.
  1386. :param center: Coordinates of the center [x, y]
  1387. :type center: list
  1388. :param radius: Radius of the arc.
  1389. :type radius: float
  1390. :param start: Starting angle in radians
  1391. :type start: float
  1392. :param stop: End angle in radians
  1393. :type stop: float
  1394. :param direction: Orientation of the arc, "CW" or "CCW"
  1395. :type direction: string
  1396. :param steps_per_circ: Number of straight line segments to
  1397. represent a circle.
  1398. :type steps_per_circ: int
  1399. :return: The desired arc, as list of tuples
  1400. :rtype: list
  1401. """
  1402. # TODO: Resolution should be established by fraction of total length, not angle.
  1403. da_sign = {"cw": -1.0, "ccw": 1.0}
  1404. points = []
  1405. if direction == "ccw" and stop <= start:
  1406. stop += 2*pi
  1407. if direction == "cw" and stop >= start:
  1408. stop -= 2*pi
  1409. angle = abs(stop - start)
  1410. #angle = stop-start
  1411. steps = max([int(ceil(angle/(2*pi)*steps_per_circ)), 2])
  1412. delta_angle = da_sign[direction]*angle*1.0/steps
  1413. for i in range(steps+1):
  1414. theta = start + delta_angle*i
  1415. points.append((center[0]+radius*cos(theta), center[1]+radius*sin(theta)))
  1416. return points
  1417. def clear_poly(poly, tooldia, overlap=0.1):
  1418. """
  1419. Creates a list of Shapely geometry objects covering the inside
  1420. of a Shapely.Polygon. Use for removing all the copper in a region
  1421. or bed flattening.
  1422. :param poly: Target polygon
  1423. :type poly: Shapely.Polygon
  1424. :param tooldia: Diameter of the tool
  1425. :type tooldia: float
  1426. :param overlap: Fraction of the tool diameter to overlap
  1427. in each pass.
  1428. :type overlap: float
  1429. :return: list of Shapely.Polygon
  1430. :rtype: list
  1431. """
  1432. poly_cuts = [poly.buffer(-tooldia/2.0)]
  1433. while True:
  1434. poly = poly_cuts[-1].buffer(-tooldia*(1-overlap))
  1435. if poly.area > 0:
  1436. poly_cuts.append(poly)
  1437. else:
  1438. break
  1439. return poly_cuts
  1440. def find_polygon(poly_set, point):
  1441. """
  1442. Return the first polygon in the list of polygons poly_set
  1443. that contains the given point.
  1444. """
  1445. p = Point(point)
  1446. for poly in poly_set:
  1447. if poly.contains(p):
  1448. return poly
  1449. return None
  1450. def to_dict(geo):
  1451. output = ''
  1452. if isinstance(geo, BaseGeometry):
  1453. return {
  1454. "__class__": "Shply",
  1455. "__inst__": sdumps(geo)
  1456. }
  1457. return geo
  1458. def dict2obj(d):
  1459. if '__class__' in d and '__inst__' in d:
  1460. # For now assume all classes are Shapely geometry.
  1461. return sloads(d['__inst__'])
  1462. else:
  1463. return d
  1464. def plotg(geo):
  1465. try:
  1466. _ = iter(geo)
  1467. except:
  1468. geo = [geo]
  1469. for g in geo:
  1470. if type(g) == Polygon:
  1471. x, y = g.exterior.coords.xy
  1472. plot(x, y)
  1473. for ints in g.interiors:
  1474. x, y = ints.coords.xy
  1475. plot(x, y)
  1476. continue
  1477. if type(g) == LineString or type(g) == LinearRing:
  1478. x, y = g.coords.xy
  1479. plot(x, y)
  1480. continue
  1481. if type(g) == Point:
  1482. x, y = g.coords.xy
  1483. plot(x, y, 'o')
  1484. continue
  1485. try:
  1486. _ = iter(g)
  1487. plotg(g)
  1488. except:
  1489. print "Cannot plot:", str(type(g))
  1490. continue
  1491. def parse_gerber_number(strnumber, frac_digits):
  1492. """
  1493. Parse a single number of Gerber coordinates.
  1494. :param strnumber: String containing a number in decimal digits
  1495. from a coordinate data block, possibly with a leading sign.
  1496. :type strnumber: str
  1497. :param frac_digits: Number of digits used for the fractional
  1498. part of the number
  1499. :type frac_digits: int
  1500. :return: The number in floating point.
  1501. :rtype: float
  1502. """
  1503. return int(strnumber)*(10**(-frac_digits))
  1504. def parse_gerber_coords(gstr, int_digits, frac_digits):
  1505. """
  1506. Parse Gerber coordinates
  1507. :param gstr: Line of G-Code containing coordinates.
  1508. :type gstr: str
  1509. :param int_digits: Number of digits in integer part of a number.
  1510. :type int_digits: int
  1511. :param frac_digits: Number of digits in frac_digits part of a number.
  1512. :type frac_digits: int
  1513. :return: [x, y] coordinates.
  1514. :rtype: list
  1515. """
  1516. global gerbx, gerby
  1517. xindex = gstr.find("X")
  1518. yindex = gstr.find("Y")
  1519. index = gstr.find("D")
  1520. if xindex == -1:
  1521. x = gerbx
  1522. y = int(gstr[(yindex+1):index])*(10**(-frac_digits))
  1523. elif yindex == -1:
  1524. y = gerby
  1525. x = int(gstr[(xindex+1):index])*(10**(-frac_digits))
  1526. else:
  1527. x = int(gstr[(xindex+1):yindex])*(10**(-frac_digits))
  1528. y = int(gstr[(yindex+1):index])*(10**(-frac_digits))
  1529. gerbx = x
  1530. gerby = y
  1531. return [x, y]