camlib.py 80 KB

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