camlib.py 78 KB

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