camlib.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276
  1. ############################################################
  2. # Author: Juan Pablo Caram #
  3. # Date: 2/5/2014 #
  4. # caram.cl #
  5. ############################################################
  6. from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos
  7. from matplotlib.figure import Figure
  8. import re
  9. # See: http://toblerity.org/shapely/manual.html
  10. from shapely.geometry import Polygon, LineString, Point, LinearRing
  11. from shapely.geometry import MultiPoint, MultiPolygon
  12. from shapely.geometry import box as shply_box
  13. from shapely.ops import cascaded_union
  14. import shapely.affinity as affinity
  15. from shapely.wkt import loads as sloads
  16. from shapely.wkt import dumps as sdumps
  17. from shapely.geometry.base import BaseGeometry
  18. # Used for solid polygons in Matplotlib
  19. from descartes.patch import PolygonPatch
  20. import simplejson as json
  21. # TODO: Commented for FlatCAM packaging with cx_freeze
  22. #from matplotlib.pyplot import plot
  23. class Geometry:
  24. def __init__(self):
  25. # Units (in or mm)
  26. self.units = 'in'
  27. # Final geometry: MultiPolygon
  28. self.solid_geometry = None
  29. # Attributes to be included in serialization
  30. self.ser_attrs = ['units', 'solid_geometry']
  31. def isolation_geometry(self, offset):
  32. """
  33. Creates contours around geometry at a given
  34. offset distance.
  35. """
  36. return self.solid_geometry.buffer(offset)
  37. def bounds(self):
  38. """
  39. Returns coordinates of rectangular bounds
  40. of geometry: (xmin, ymin, xmax, ymax).
  41. """
  42. if self.solid_geometry is None:
  43. print "Warning: solid_geometry not computed yet."
  44. return (0, 0, 0, 0)
  45. if type(self.solid_geometry) == list:
  46. # TODO: This can be done faster. See comment from Shapely mailing lists.
  47. return cascaded_union(self.solid_geometry).bounds
  48. else:
  49. return self.solid_geometry.bounds
  50. def size(self):
  51. """
  52. Returns (width, height) of rectangular
  53. bounds of geometry.
  54. """
  55. if self.solid_geometry is None:
  56. print "Warning: solid_geometry not computed yet."
  57. return 0
  58. bounds = self.bounds()
  59. return (bounds[2]-bounds[0], bounds[3]-bounds[1])
  60. def get_empty_area(self, boundary=None):
  61. """
  62. Returns the complement of self.solid_geometry within
  63. the given boundary polygon. If not specified, it defaults to
  64. the rectangular bounding box of self.solid_geometry.
  65. """
  66. if boundary is None:
  67. boundary = self.solid_geometry.envelope
  68. return boundary.difference(self.solid_geometry)
  69. def clear_polygon(self, polygon, tooldia, overlap=0.15):
  70. """
  71. Creates geometry inside a polygon for a tool to cover
  72. the whole area.
  73. """
  74. poly_cuts = [polygon.buffer(-tooldia/2.0)]
  75. while True:
  76. polygon = poly_cuts[-1].buffer(-tooldia*(1-overlap))
  77. if polygon.area > 0:
  78. poly_cuts.append(polygon)
  79. else:
  80. break
  81. return poly_cuts
  82. def scale(self, factor):
  83. """
  84. Scales all of the object's geometry by a given factor. Override
  85. this method.
  86. :param factor: Number by which to scale.
  87. :type factor: float
  88. :return: None
  89. :rtype: None
  90. """
  91. return
  92. def convert_units(self, units):
  93. """
  94. Converts the units of the object to ``units`` by scaling all
  95. the geometry appropriately. This call ``scale()``. Don't call
  96. it again in descendents.
  97. :param units: "IN" or "MM"
  98. :type units: str
  99. :return: Scaling factor resulting from unit change.
  100. :rtype: float
  101. """
  102. print "Geometry.convert_units()"
  103. if units.upper() == self.units.upper():
  104. return 1.0
  105. if units.upper() == "MM":
  106. factor = 25.4
  107. elif units.upper() == "IN":
  108. factor = 1/25.4
  109. else:
  110. print "Unsupported units:", units
  111. return 1.0
  112. self.units = units
  113. self.scale(factor)
  114. return factor
  115. def to_dict(self):
  116. """
  117. Returns a respresentation of the object as a dictionary.
  118. Attributes to include are listed in ``self.ser_attrs``.
  119. :return: A dictionary-encoded copy of the object.
  120. :rtype: dict
  121. """
  122. d = {}
  123. for attr in self.ser_attrs:
  124. d[attr] = getattr(self, attr)
  125. return d
  126. def from_dict(self, d):
  127. """
  128. Sets object's attributes from a dictionary.
  129. Attributes to include are listed in ``self.ser_attrs``.
  130. """
  131. for attr in self.ser_attrs:
  132. setattr(self, attr, d[attr])
  133. class Gerber (Geometry):
  134. """
  135. **ATTRIBUTES**
  136. * ``apertures`` (dict): The keys are names/identifiers of each aperture.
  137. The values are dictionaries key/value pairs which describe the aperture. The
  138. type key is always present and the rest depend on the key:
  139. +-----------+-----------------------------------+
  140. | Key | Value |
  141. +===========+===================================+
  142. | type | (str) "C", "R", or "O" |
  143. +-----------+-----------------------------------+
  144. | others | Depend on ``type`` |
  145. +-----------+-----------------------------------+
  146. * ``paths`` (list): A path is described by a line an aperture that follows that
  147. line. Each paths[i] is a dictionary:
  148. +------------+------------------------------------------------+
  149. | Key | Value |
  150. +============+================================================+
  151. | linestring | (Shapely.LineString) The actual path. |
  152. +------------+------------------------------------------------+
  153. | aperture | (str) The key for an aperture in apertures. |
  154. +------------+------------------------------------------------+
  155. * ``flashes`` (list): Flashes are single-point strokes of an aperture. Each
  156. is a dictionary:
  157. +------------+------------------------------------------------+
  158. | Key | Value |
  159. +============+================================================+
  160. | loc | (list) [x (float), y (float)] coordinates. |
  161. +------------+------------------------------------------------+
  162. | aperture | (str) The key for an aperture in apertures. |
  163. +------------+------------------------------------------------+
  164. * ``regions`` (list): Are surfaces defined by a polygon (Shapely.Polygon),
  165. which have an exterior and zero or more interiors. An aperture is also
  166. associated with a region. Each is a dictionary:
  167. +------------+-----------------------------------------------------+
  168. | Key | Value |
  169. +============+=====================================================+
  170. | polygon | (Shapely.Polygon) The polygon defining the region. |
  171. +------------+-----------------------------------------------------+
  172. | aperture | (str) The key for an aperture in apertures. |
  173. +------------+-----------------------------------------------------+
  174. * ``flash_geometry`` (list): List of (Shapely) geometric object resulting
  175. from ``flashes``. These are generated from ``flashes`` in ``do_flashes()``.
  176. * ``buffered_paths`` (list): List of (Shapely) polygons resulting from
  177. *buffering* (or thickening) the ``paths`` with the aperture. These are
  178. generated from ``paths`` in ``buffer_paths()``.
  179. **USAGE**::
  180. g = Gerber()
  181. g.parse_file(filename)
  182. g.create_geometry()
  183. do_something(s.solid_geometry)
  184. """
  185. def __init__(self):
  186. """
  187. The constructor takes no parameters. Use ``gerber.parse_files()``
  188. or ``gerber.parse_lines()`` to populate the object from Gerber source.
  189. :return: Gerber object
  190. :rtype: Gerber
  191. """
  192. # Initialize parent
  193. Geometry.__init__(self)
  194. # Number format
  195. self.digits = 3
  196. """Number of integer digits in Gerber numbers. Used during parsing."""
  197. self.fraction = 4
  198. """Number of fraction digits in Gerber numbers. Used during parsing."""
  199. ## Gerber elements ##
  200. # Apertures {'id':{'type':chr,
  201. # ['size':float], ['width':float],
  202. # ['height':float]}, ...}
  203. self.apertures = {}
  204. # Paths [{'linestring':LineString, 'aperture':str}]
  205. self.paths = []
  206. # Buffered Paths [Polygon]
  207. # Paths transformed into Polygons by
  208. # offsetting the aperture size/2
  209. self.buffered_paths = []
  210. # Polygon regions [{'polygon':Polygon, 'aperture':str}]
  211. self.regions = []
  212. # Flashes [{'loc':[float,float], 'aperture':str}]
  213. self.flashes = []
  214. # Geometry from flashes
  215. self.flash_geometry = []
  216. # Attributes to be included in serialization
  217. # Always append to it because it carries contents
  218. # from Geometry.
  219. self.ser_attrs += ['digits', 'fraction', 'apertures', 'paths',
  220. 'buffered_paths', 'regions', 'flashes',
  221. 'flash_geometry']
  222. def scale(self, factor):
  223. """
  224. Scales the objects' geometry on the XY plane by a given factor.
  225. These are:
  226. * ``apertures``
  227. * ``paths``
  228. * ``regions``
  229. * ``flashes``
  230. Then ``buffered_paths``, ``flash_geometry`` and ``solid_geometry``
  231. are re-created with ``self.create_geometry()``.
  232. :param factor: Number by which to scale.
  233. :type factor: float
  234. :rtype : None
  235. """
  236. # Apertures
  237. print "Scaling apertures..."
  238. for apid in self.apertures:
  239. for param in self.apertures[apid]:
  240. if param != "type": # All others are dimensions.
  241. print "Tool:", apid, "Parameter:", param
  242. self.apertures[apid][param] *= factor
  243. # Paths
  244. print "Scaling paths..."
  245. for path in self.paths:
  246. path['linestring'] = affinity.scale(path['linestring'],
  247. factor, factor, origin=(0, 0))
  248. # Flashes
  249. print "Scaling flashes..."
  250. for fl in self.flashes:
  251. # TODO: Shouldn't 'loc' be a numpy.array()?
  252. fl['loc'][0] *= factor
  253. fl['loc'][1] *= factor
  254. # Regions
  255. print "Scaling regions..."
  256. for reg in self.regions:
  257. reg['polygon'] = affinity.scale(reg['polygon'], factor, factor,
  258. origin=(0, 0))
  259. # Now buffered_paths, flash_geometry and solid_geometry
  260. self.create_geometry()
  261. def fix_regions(self):
  262. """
  263. Overwrites the region polygons with fixed
  264. versions if found to be invalid (according to Shapely).
  265. """
  266. for region in self.regions:
  267. if not region['polygon'].is_valid:
  268. region['polygon'] = region['polygon'].buffer(0)
  269. def buffer_paths(self):
  270. self.buffered_paths = []
  271. for path in self.paths:
  272. width = self.apertures[path["aperture"]]["size"]
  273. self.buffered_paths.append(path["linestring"].buffer(width/2))
  274. def aperture_parse(self, gline):
  275. """
  276. Parse gerber aperture definition into dictionary of apertures.
  277. The following kinds and their attributes are supported:
  278. * *Circular (C)*: size (float)
  279. * *Rectangle (R)*: width (float), height (float)
  280. * *Obround (O)*: width (float), height (float).
  281. :param gline: Line of Gerber code known to have an aperture definition.
  282. :type gline: str
  283. :return: Identifier of the aperture.
  284. :rtype: str
  285. """
  286. indexstar = gline.find("*")
  287. indexc = gline.find("C,")
  288. if indexc != -1: # Circle, example: %ADD11C,0.1*%
  289. apid = gline[4:indexc]
  290. self.apertures[apid] = {"type": "C",
  291. "size": float(gline[indexc+2:indexstar])}
  292. return apid
  293. indexr = gline.find("R,")
  294. if indexr != -1: # Rectangle, example: %ADD15R,0.05X0.12*%
  295. apid = gline[4:indexr]
  296. indexx = gline.find("X")
  297. self.apertures[apid] = {"type": "R",
  298. "width": float(gline[indexr+2:indexx]),
  299. "height": float(gline[indexx+1:indexstar])}
  300. return apid
  301. indexo = gline.find("O,")
  302. if indexo != -1: # Obround
  303. apid = gline[4:indexo]
  304. indexx = gline.find("X")
  305. self.apertures[apid] = {"type": "O",
  306. "width": float(gline[indexo+2:indexx]),
  307. "height": float(gline[indexx+1:indexstar])}
  308. return apid
  309. print "WARNING: Aperture not implemented:", gline
  310. return None
  311. def parse_file(self, filename):
  312. """
  313. Calls Gerber.parse_lines() with array of lines
  314. read from the given file.
  315. """
  316. gfile = open(filename, 'r')
  317. gstr = gfile.readlines()
  318. gfile.close()
  319. self.parse_lines(gstr)
  320. def parse_lines(self, glines):
  321. """
  322. Main Gerber parser.
  323. """
  324. # Mode (IN/MM)
  325. mode_re = re.compile(r'^%MO(IN|MM)\*%$')
  326. path = [] # Coordinates of the current path
  327. last_path_aperture = None
  328. current_aperture = None
  329. for gline in glines:
  330. if gline.find("D01*") != -1: # pen down
  331. path.append(coord(gline, self.digits, self.fraction))
  332. last_path_aperture = current_aperture
  333. continue
  334. if gline.find("D02*") != -1: # pen up
  335. if len(path) > 1:
  336. # Path completed, create shapely LineString
  337. self.paths.append({"linestring": LineString(path),
  338. "aperture": last_path_aperture})
  339. path = [coord(gline, self.digits, self.fraction)]
  340. continue
  341. indexd3 = gline.find("D03*")
  342. if indexd3 > 0: # Flash
  343. self.flashes.append({"loc": coord(gline, self.digits, self.fraction),
  344. "aperture": current_aperture})
  345. continue
  346. if indexd3 == 0: # Flash?
  347. print "WARNING: Uninplemented flash style:", gline
  348. continue
  349. if gline.find("G37*") != -1: # end region
  350. # Only one path defines region?
  351. self.regions.append({"polygon": Polygon(path),
  352. "aperture": last_path_aperture})
  353. path = []
  354. continue
  355. if gline.find("%ADD") != -1: # aperture definition
  356. self.aperture_parse(gline) # adds element to apertures
  357. continue
  358. indexstar = gline.find("*")
  359. if gline.find("D") == 0: # Aperture change
  360. current_aperture = gline[1:indexstar]
  361. continue
  362. if gline.find("G54D") == 0: # Aperture change (deprecated)
  363. current_aperture = gline[4:indexstar]
  364. continue
  365. if gline.find("%FS") != -1: # Format statement
  366. indexx = gline.find("X")
  367. self.digits = int(gline[indexx + 1])
  368. self.fraction = int(gline[indexx + 2])
  369. continue
  370. # Mode (IN/MM)
  371. match = mode_re.search(gline)
  372. if match:
  373. self.units = match.group(1)
  374. continue
  375. print "WARNING: Line ignored:", gline
  376. if len(path) > 1:
  377. # EOF, create shapely LineString if something still in path
  378. self.paths.append({"linestring": LineString(path),
  379. "aperture": last_path_aperture})
  380. def do_flashes(self):
  381. """
  382. Creates geometry for Gerber flashes (aperture on a single point).
  383. """
  384. self.flash_geometry = []
  385. for flash in self.flashes:
  386. aperture = self.apertures[flash['aperture']]
  387. if aperture['type'] == 'C': # Circles
  388. circle = Point(flash['loc']).buffer(aperture['size']/2)
  389. self.flash_geometry.append(circle)
  390. continue
  391. if aperture['type'] == 'R': # Rectangles
  392. loc = flash['loc']
  393. width = aperture['width']
  394. height = aperture['height']
  395. minx = loc[0] - width/2
  396. maxx = loc[0] + width/2
  397. miny = loc[1] - height/2
  398. maxy = loc[1] + height/2
  399. rectangle = shply_box(minx, miny, maxx, maxy)
  400. self.flash_geometry.append(rectangle)
  401. continue
  402. if aperture['type'] == 'O': # Obround
  403. loc = flash['loc']
  404. width = aperture['width']
  405. height = aperture['height']
  406. if width > height:
  407. p1 = Point(loc[0] + 0.5*(width-height), loc[1])
  408. p2 = Point(loc[0] - 0.5*(width-height), loc[1])
  409. c1 = p1.buffer(height*0.5)
  410. c2 = p2.buffer(height*0.5)
  411. else:
  412. p1 = Point(loc[0], loc[1] + 0.5*(height-width))
  413. p2 = Point(loc[0], loc[1] - 0.5*(height-width))
  414. c1 = p1.buffer(width*0.5)
  415. c2 = p2.buffer(width*0.5)
  416. obround = cascaded_union([c1, c2]).convex_hull
  417. self.flash_geometry.append(obround)
  418. continue
  419. print "WARNING: Aperture type %s not implemented" % (aperture['type'])
  420. def create_geometry(self):
  421. """
  422. Geometry from a Gerber file is made up entirely of polygons.
  423. Every stroke (linear or circular) has an aperture which gives
  424. it thickness. Additionally, aperture strokes have non-zero area,
  425. and regions naturally do as well.
  426. :rtype : None
  427. :return: None
  428. """
  429. # if len(self.buffered_paths) == 0:
  430. # self.buffer_paths()
  431. print "... buffer_paths()"
  432. self.buffer_paths()
  433. print "... fix_regions()"
  434. self.fix_regions()
  435. print "... do_flashes()"
  436. self.do_flashes()
  437. print "... cascaded_union()"
  438. self.solid_geometry = cascaded_union(
  439. self.buffered_paths +
  440. [poly['polygon'] for poly in self.regions] +
  441. self.flash_geometry)
  442. def get_bounding_box(self, margin=0.0, rounded=False):
  443. """
  444. Creates and returns a rectangular polygon bounding at a distance of
  445. margin from the object's ``solid_geometry``. If margin > 0, the polygon
  446. can optionally have rounded corners of radius equal to margin.
  447. :param margin: Distance to enlarge the rectangular bounding
  448. box in both positive and negative, x and y axes.
  449. :type margin: float
  450. :param rounded: Wether or not to have rounded corners.
  451. :type rounded: bool
  452. :return: The bounding box.
  453. :rtype: Shapely.Polygon
  454. """
  455. bbox = self.solid_geometry.envelope.buffer(margin)
  456. if not rounded:
  457. bbox = bbox.envelope
  458. return bbox
  459. class Excellon(Geometry):
  460. """
  461. *ATTRIBUTES*
  462. * ``tools`` (dict): The key is the tool name and the value is
  463. the size (diameter).
  464. * ``drills`` (list): Each is a dictionary:
  465. ================ ====================================
  466. Key Value
  467. ================ ====================================
  468. point (Shapely.Point) Where to drill
  469. tool (str) A key in ``tools``
  470. ================ ====================================
  471. """
  472. def __init__(self):
  473. """
  474. The constructor takes no parameters.
  475. :return: Excellon object.
  476. :rtype: Excellon
  477. """
  478. Geometry.__init__(self)
  479. self.tools = {}
  480. self.drills = []
  481. # Trailing "T" or leading "L"
  482. self.zeros = ""
  483. # Attributes to be included in serialization
  484. # Always append to it because it carries contents
  485. # from Geometry.
  486. self.ser_attrs += ['tools', 'drills', 'zeros']
  487. def parse_file(self, filename):
  488. efile = open(filename, 'r')
  489. estr = efile.readlines()
  490. efile.close()
  491. self.parse_lines(estr)
  492. def parse_lines(self, elines):
  493. """
  494. Main Excellon parser.
  495. """
  496. units_re = re.compile(r'^(INCH|METRIC)(?:,([TL])Z)?$')
  497. current_tool = ""
  498. for eline in elines:
  499. ## Tool definitions ##
  500. # TODO: Verify all this
  501. indexT = eline.find("T")
  502. indexC = eline.find("C")
  503. indexF = eline.find("F")
  504. # Type 1
  505. if indexT != -1 and indexC > indexT and indexF > indexC:
  506. tool = eline[1:indexC]
  507. spec = eline[indexC+1:indexF]
  508. self.tools[tool] = float(spec)
  509. continue
  510. # Type 2
  511. # TODO: Is this inches?
  512. #indexsp = eline.find(" ")
  513. #indexin = eline.find("in")
  514. #if indexT != -1 and indexsp > indexT and indexin > indexsp:
  515. # tool = eline[1:indexsp]
  516. # spec = eline[indexsp+1:indexin]
  517. # self.tools[tool] = spec
  518. # continue
  519. # Type 3
  520. if indexT != -1 and indexC > indexT:
  521. tool = eline[1:indexC]
  522. spec = eline[indexC+1:-1]
  523. self.tools[tool] = float(spec)
  524. continue
  525. ## Tool change
  526. if indexT == 0:
  527. current_tool = eline[1:-1]
  528. continue
  529. ## Drill
  530. indexx = eline.find("X")
  531. indexy = eline.find("Y")
  532. if indexx != -1 and indexy != -1:
  533. x = float(int(eline[indexx+1:indexy])/10000.0)
  534. y = float(int(eline[indexy+1:-1])/10000.0)
  535. self.drills.append({'point': Point((x, y)), 'tool': current_tool})
  536. continue
  537. # Units and number format
  538. match = units_re.match(eline)
  539. if match:
  540. self.zeros = match.group(2) # "T" or "L"
  541. self.units = {"INCH": "IN", "METRIC": "MM"}[match.group(1)]
  542. print "WARNING: Line ignored:", eline
  543. def create_geometry(self):
  544. self.solid_geometry = []
  545. sizes = {}
  546. for tool in self.tools:
  547. sizes[tool] = float(self.tools[tool])
  548. for drill in self.drills:
  549. poly = Point(drill['point']).buffer(sizes[drill['tool']]/2.0)
  550. self.solid_geometry.append(poly)
  551. self.solid_geometry = cascaded_union(self.solid_geometry)
  552. def scale(self, factor):
  553. """
  554. Scales geometry on the XY plane in the object by a given factor.
  555. Tool sizes, feedrates an Z-plane dimensions are untouched.
  556. :param factor: Number by which to scale the object.
  557. :type factor: float
  558. :return: None
  559. :rtype: NOne
  560. """
  561. # Drills
  562. for drill in self.drills:
  563. drill['point'] = affinity.scale(drill['point'], factor, factor, origin=(0, 0))
  564. def convert_units(self, units):
  565. factor = Geometry.convert_units(self, units)
  566. # Tools
  567. for tname in self.tools:
  568. self.tools[tname] *= factor
  569. return factor
  570. class CNCjob(Geometry):
  571. """
  572. Represents work to be done by a CNC machine.
  573. *ATTRIBUTES*
  574. * ``gcode_parsed`` (list): Each is a dictionary:
  575. ===================== =========================================
  576. Key Value
  577. ===================== =========================================
  578. geom (Shapely.LineString) Tool path (XY plane)
  579. kind (string) "AB", A is "T" (travel) or
  580. "C" (cut). B is "F" (fast) or "S" (slow).
  581. ===================== =========================================
  582. """
  583. def __init__(self, units="in", kind="generic", z_move=0.1,
  584. feedrate=3.0, z_cut=-0.002, tooldia=0.0):
  585. Geometry.__init__(self)
  586. self.kind = kind
  587. self.units = units
  588. self.z_cut = z_cut
  589. self.z_move = z_move
  590. self.feedrate = feedrate
  591. self.tooldia = tooldia
  592. self.unitcode = {"IN": "G20", "MM": "G21"}
  593. self.pausecode = "G04 P1"
  594. self.feedminutecode = "G94"
  595. self.absolutecode = "G90"
  596. self.gcode = ""
  597. self.input_geometry_bounds = None
  598. self.gcode_parsed = None
  599. self.steps_per_circ = 20 # Used when parsing G-code arcs
  600. # Attributes to be included in serialization
  601. # Always append to it because it carries contents
  602. # from Geometry.
  603. self.ser_attrs += ['kind', 'z_cut', 'z_move', 'feedrate', 'tooldia',
  604. 'gcode', 'input_geometry_bounds', 'gcode_parsed',
  605. 'steps_per_circ']
  606. def convert_units(self, units):
  607. factor = Geometry.convert_units(self, units)
  608. print "CNCjob.convert_units()"
  609. self.z_cut *= factor
  610. self.z_move *= factor
  611. self.feedrate *= factor
  612. self.tooldia *= factor
  613. return factor
  614. def generate_from_excellon(self, exobj):
  615. """
  616. Generates G-code for drilling from Excellon object.
  617. self.gcode becomes a list, each element is a
  618. different job for each tool in the excellon code.
  619. """
  620. self.kind = "drill"
  621. self.gcode = []
  622. t = "G00 X%.4fY%.4f\n"
  623. down = "G01 Z%.4f\n" % self.z_cut
  624. up = "G01 Z%.4f\n" % self.z_move
  625. for tool in exobj.tools:
  626. points = []
  627. for drill in exobj.drill:
  628. if drill['tool'] == tool:
  629. points.append(drill['point'])
  630. gcode = self.unitcode[self.units.upper()] + "\n"
  631. gcode += self.absolutecode + "\n"
  632. gcode += self.feedminutecode + "\n"
  633. gcode += "F%.2f\n" % self.feedrate
  634. gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  635. gcode += "M03\n" # Spindle start
  636. gcode += self.pausecode + "\n"
  637. for point in points:
  638. gcode += t % point
  639. gcode += down + up
  640. gcode += t % (0, 0)
  641. gcode += "M05\n" # Spindle stop
  642. self.gcode.append(gcode)
  643. def generate_from_excellon_by_tool(self, exobj, tools="all"):
  644. """
  645. Creates gcode for this object from an Excellon object
  646. for the specified tools.
  647. @param exobj: Excellon object to process
  648. @type exobj: Excellon
  649. @param tools: Comma separated tool names
  650. @type: tools: str
  651. @return: None
  652. """
  653. print "Creating CNC Job from Excellon..."
  654. if tools == "all":
  655. tools = [tool for tool in exobj.tools]
  656. else:
  657. tools = [x.strip() for x in tools.split(",")]
  658. tools = filter(lambda y: y in exobj.tools, tools)
  659. print "Tools are:", tools
  660. points = []
  661. for drill in exobj.drills:
  662. if drill['tool'] in tools:
  663. points.append(drill['point'])
  664. print "Found %d drills." % len(points)
  665. #self.kind = "drill"
  666. self.gcode = []
  667. t = "G00 X%.4fY%.4f\n"
  668. down = "G01 Z%.4f\n" % self.z_cut
  669. up = "G01 Z%.4f\n" % self.z_move
  670. gcode = self.unitcode[self.units.upper()] + "\n"
  671. gcode += self.absolutecode + "\n"
  672. gcode += self.feedminutecode + "\n"
  673. gcode += "F%.2f\n" % self.feedrate
  674. gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  675. gcode += "M03\n" # Spindle start
  676. gcode += self.pausecode + "\n"
  677. for point in points:
  678. x, y = point.coords.xy
  679. gcode += t % (x[0], y[0])
  680. gcode += down + up
  681. gcode += t % (0, 0)
  682. gcode += "M05\n" # Spindle stop
  683. self.gcode = gcode
  684. def generate_from_geometry(self, geometry, append=True, tooldia=None):
  685. """
  686. Generates G-Code from a Geometry object.
  687. """
  688. if tooldia is not None:
  689. self.tooldia = tooldia
  690. self.input_geometry_bounds = geometry.bounds()
  691. if not append:
  692. self.gcode = ""
  693. self.gcode = self.unitcode[self.units.upper()] + "\n"
  694. self.gcode += self.absolutecode + "\n"
  695. self.gcode += self.feedminutecode + "\n"
  696. self.gcode += "F%.2f\n" % self.feedrate
  697. self.gcode += "G00 Z%.4f\n" % self.z_move # Move to travel height
  698. self.gcode += "M03\n" # Spindle start
  699. self.gcode += self.pausecode + "\n"
  700. for geo in geometry.solid_geometry:
  701. if type(geo) == Polygon:
  702. self.gcode += self.polygon2gcode(geo)
  703. continue
  704. if type(geo) == LineString or type(geo) == LinearRing:
  705. self.gcode += self.linear2gcode(geo)
  706. continue
  707. if type(geo) == Point:
  708. self.gcode += self.point2gcode(geo)
  709. continue
  710. if type(geo) == MultiPolygon:
  711. for poly in geo:
  712. self.gcode += self.polygon2gcode(poly)
  713. continue
  714. print "WARNING: G-code generation not implemented for %s" % (str(type(geo)))
  715. self.gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  716. self.gcode += "G00 X0Y0\n"
  717. self.gcode += "M05\n" # Spindle stop
  718. def pre_parse(self, gtext):
  719. """
  720. gtext is a single string with g-code
  721. """
  722. # Units: G20-inches, G21-mm
  723. units_re = re.compile(r'^G2([01])')
  724. # TODO: This has to be re-done
  725. gcmds = []
  726. lines = gtext.split("\n") # TODO: This is probably a lot of work!
  727. for line in lines:
  728. # Clean up
  729. line = line.strip()
  730. # Remove comments
  731. # NOTE: Limited to 1 bracket pair
  732. op = line.find("(")
  733. cl = line.find(")")
  734. if op > -1 and cl > op:
  735. #comment = line[op+1:cl]
  736. line = line[:op] + line[(cl+1):]
  737. # Units
  738. match = units_re.match(line)
  739. if match:
  740. self.units = {'0': "IN", '1': "MM"}[match.group(1)]
  741. # Parse GCode
  742. # 0 4 12
  743. # G01 X-0.007 Y-0.057
  744. # --> codes_idx = [0, 4, 12]
  745. codes = "NMGXYZIJFP"
  746. codes_idx = []
  747. i = 0
  748. for ch in line:
  749. if ch in codes:
  750. codes_idx.append(i)
  751. i += 1
  752. n_codes = len(codes_idx)
  753. if n_codes == 0:
  754. continue
  755. # Separate codes in line
  756. parts = []
  757. for p in range(n_codes-1):
  758. parts.append(line[codes_idx[p]:codes_idx[p+1]].strip())
  759. parts.append(line[codes_idx[-1]:].strip())
  760. # Separate codes from values
  761. cmds = {}
  762. for part in parts:
  763. cmds[part[0]] = float(part[1:])
  764. gcmds.append(cmds)
  765. return gcmds
  766. def gcode_parse(self):
  767. """
  768. G-Code parser (from self.gcode). Generates dictionary with
  769. single-segment LineString's and "kind" indicating cut or travel,
  770. fast or feedrate speed.
  771. """
  772. # Results go here
  773. geometry = []
  774. # TODO: Merge into single parser?
  775. gobjs = self.pre_parse(self.gcode)
  776. # Last known instruction
  777. current = {'X': 0.0, 'Y': 0.0, 'Z': 0.0, 'G': 0}
  778. # Process every instruction
  779. for gobj in gobjs:
  780. if 'Z' in gobj:
  781. if ('X' in gobj or 'Y' in gobj) and gobj['Z'] != current['Z']:
  782. print "WARNING: Non-orthogonal motion: From", current
  783. print " To:", gobj
  784. current['Z'] = gobj['Z']
  785. if 'G' in gobj:
  786. current['G'] = int(gobj['G'])
  787. if 'X' in gobj or 'Y' in gobj:
  788. x = 0
  789. y = 0
  790. kind = ["C", "F"] # T=travel, C=cut, F=fast, S=slow
  791. if 'X' in gobj:
  792. x = gobj['X']
  793. else:
  794. x = current['X']
  795. if 'Y' in gobj:
  796. y = gobj['Y']
  797. else:
  798. y = current['Y']
  799. if current['Z'] > 0:
  800. kind[0] = 'T'
  801. if current['G'] > 0:
  802. kind[1] = 'S'
  803. arcdir = [None, None, "cw", "ccw"]
  804. if current['G'] in [0, 1]: # line
  805. geometry.append({'geom': LineString([(current['X'], current['Y']),
  806. (x, y)]), 'kind': kind})
  807. if current['G'] in [2, 3]: # arc
  808. center = [gobj['I'] + current['X'], gobj['J'] + current['Y']]
  809. radius = sqrt(gobj['I']**2 + gobj['J']**2)
  810. start = arctan2(-gobj['J'], -gobj['I'])
  811. stop = arctan2(-center[1]+y, -center[0]+x)
  812. geometry.append({'geom': arc(center, radius, start, stop,
  813. arcdir[current['G']],
  814. self.steps_per_circ),
  815. 'kind': kind})
  816. # Update current instruction
  817. for code in gobj:
  818. current[code] = gobj[code]
  819. #self.G_geometry = geometry
  820. self.gcode_parsed = geometry
  821. return geometry
  822. # def plot(self, tooldia=None, dpi=75, margin=0.1,
  823. # color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]},
  824. # alpha={"T": 0.3, "C": 1.0}):
  825. # """
  826. # Creates a Matplotlib figure with a plot of the
  827. # G-code job.
  828. # """
  829. # if tooldia is None:
  830. # tooldia = self.tooldia
  831. #
  832. # fig = Figure(dpi=dpi)
  833. # ax = fig.add_subplot(111)
  834. # ax.set_aspect(1)
  835. # xmin, ymin, xmax, ymax = self.input_geometry_bounds
  836. # ax.set_xlim(xmin-margin, xmax+margin)
  837. # ax.set_ylim(ymin-margin, ymax+margin)
  838. #
  839. # if tooldia == 0:
  840. # for geo in self.gcode_parsed:
  841. # linespec = '--'
  842. # linecolor = color[geo['kind'][0]][1]
  843. # if geo['kind'][0] == 'C':
  844. # linespec = 'k-'
  845. # x, y = geo['geom'].coords.xy
  846. # ax.plot(x, y, linespec, color=linecolor)
  847. # else:
  848. # for geo in self.gcode_parsed:
  849. # poly = geo['geom'].buffer(tooldia/2.0)
  850. # patch = PolygonPatch(poly, facecolor=color[geo['kind'][0]][0],
  851. # edgecolor=color[geo['kind'][0]][1],
  852. # alpha=alpha[geo['kind'][0]], zorder=2)
  853. # ax.add_patch(patch)
  854. #
  855. # return fig
  856. def plot2(self, axes, tooldia=None, dpi=75, margin=0.1,
  857. color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]},
  858. alpha={"T": 0.3, "C": 1.0}):
  859. """
  860. Plots the G-code job onto the given axes.
  861. """
  862. if tooldia is None:
  863. tooldia = self.tooldia
  864. if tooldia == 0:
  865. for geo in self.gcode_parsed:
  866. linespec = '--'
  867. linecolor = color[geo['kind'][0]][1]
  868. if geo['kind'][0] == 'C':
  869. linespec = 'k-'
  870. x, y = geo['geom'].coords.xy
  871. axes.plot(x, y, linespec, color=linecolor)
  872. else:
  873. for geo in self.gcode_parsed:
  874. poly = geo['geom'].buffer(tooldia/2.0)
  875. patch = PolygonPatch(poly, facecolor=color[geo['kind'][0]][0],
  876. edgecolor=color[geo['kind'][0]][1],
  877. alpha=alpha[geo['kind'][0]], zorder=2)
  878. axes.add_patch(patch)
  879. def create_geometry(self):
  880. self.solid_geometry = cascaded_union([geo['geom'] for geo in self.gcode_parsed])
  881. def polygon2gcode(self, polygon):
  882. """
  883. Creates G-Code for the exterior and all interior paths
  884. of a polygon.
  885. :param polygon: A Shapely.Polygon
  886. :type polygon: Shapely.Polygon
  887. """
  888. gcode = ""
  889. t = "G0%d X%.4fY%.4f\n"
  890. path = list(polygon.exterior.coords) # Polygon exterior
  891. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  892. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  893. for pt in path[1:]:
  894. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  895. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  896. for ints in polygon.interiors: # Polygon interiors
  897. path = list(ints.coords)
  898. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  899. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  900. for pt in path[1:]:
  901. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  902. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  903. return gcode
  904. def linear2gcode(self, linear):
  905. gcode = ""
  906. t = "G0%d X%.4fY%.4f\n"
  907. path = list(linear.coords)
  908. gcode += t%(0, path[0][0], path[0][1]) # Move to first point
  909. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  910. for pt in path[1:]:
  911. gcode += t % (1, pt[0], pt[1]) # Linear motion to point
  912. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  913. return gcode
  914. def point2gcode(self, point):
  915. # TODO: This is not doing anything.
  916. gcode = ""
  917. t = "G0%d X%.4fY%.4f\n"
  918. path = list(point.coords)
  919. gcode += t % (0, path[0][0], path[0][1]) # Move to first point
  920. gcode += "G01 Z%.4f\n" % self.z_cut # Start cutting
  921. gcode += "G00 Z%.4f\n" % self.z_move # Stop cutting
  922. def scale(self, factor):
  923. """
  924. Scales all the geometry on the XY plane in the object by the
  925. given factor. Tool sizes, feedrates, or Z-axis dimensions are
  926. not altered.
  927. :param factor: Number by which to scale the object.
  928. :type factor: float
  929. :return: None
  930. :rtype: None
  931. """
  932. for g in self.gcode_parsed:
  933. g['geom'] = affinity.scale(g['geom'], factor, factor, origin=(0, 0))
  934. self.create_geometry()
  935. def get_bounds(geometry_set):
  936. xmin = Inf
  937. ymin = Inf
  938. xmax = -Inf
  939. ymax = -Inf
  940. print "Getting bounds of:", str(geometry_set)
  941. for gs in geometry_set:
  942. try:
  943. gxmin, gymin, gxmax, gymax = geometry_set[gs].bounds()
  944. xmin = min([xmin, gxmin])
  945. ymin = min([ymin, gymin])
  946. xmax = max([xmax, gxmax])
  947. ymax = max([ymax, gymax])
  948. except:
  949. print "DEV WARNING: Tried to get bounds of empty geometry."
  950. return [xmin, ymin, xmax, ymax]
  951. def arc(center, radius, start, stop, direction, steps_per_circ):
  952. """
  953. Creates a Shapely.LineString for the specified arc.
  954. @param center: Coordinates of the center [x, y]
  955. @type center: list
  956. @param radius: Radius of the arc.
  957. @type radius: float
  958. @param start: Starting angle in radians
  959. @type start: float
  960. @param stop: End angle in radians
  961. @type stop: float
  962. @param direction: Orientation of the arc, "CW" or "CCW"
  963. @type direction: string
  964. @param steps_per_circ: Number of straight line segments to
  965. represent a circle.
  966. @type steps_per_circ: int
  967. """
  968. da_sign = {"cw": -1.0, "ccw": 1.0}
  969. points = []
  970. if direction == "ccw" and stop <= start:
  971. stop += 2*pi
  972. if direction == "cw" and stop >= start:
  973. stop -= 2*pi
  974. angle = abs(stop - start)
  975. #angle = stop-start
  976. steps = max([int(ceil(angle/(2*pi)*steps_per_circ)), 2])
  977. delta_angle = da_sign[direction]*angle*1.0/steps
  978. for i in range(steps+1):
  979. theta = start + delta_angle*i
  980. points.append([center[0]+radius*cos(theta), center[1]+radius*sin(theta)])
  981. return LineString(points)
  982. def clear_poly(poly, tooldia, overlap=0.1):
  983. """
  984. Creates a list of Shapely geometry objects covering the inside
  985. of a Shapely.Polygon. Use for removing all the copper in a region
  986. or bed flattening.
  987. @param poly: Target polygon
  988. @type poly: Shapely.Polygon
  989. @param tooldia: Diameter of the tool
  990. @type tooldia: float
  991. @param overlap: Fraction of the tool diameter to overlap
  992. in each pass.
  993. @type overlap: float
  994. @return list of Shapely.Polygon
  995. """
  996. poly_cuts = [poly.buffer(-tooldia/2.0)]
  997. while True:
  998. poly = poly_cuts[-1].buffer(-tooldia*(1-overlap))
  999. if poly.area > 0:
  1000. poly_cuts.append(poly)
  1001. else:
  1002. break
  1003. return poly_cuts
  1004. def find_polygon(poly_set, point):
  1005. """
  1006. Return the first polygon in the list of polygons poly_set
  1007. that contains the given point.
  1008. """
  1009. p = Point(point)
  1010. for poly in poly_set:
  1011. if poly.contains(p):
  1012. return poly
  1013. return None
  1014. def to_dict(geo):
  1015. output = ''
  1016. if isinstance(geo, BaseGeometry):
  1017. return {
  1018. "__class__": "Shply",
  1019. "__inst__": sdumps(geo)
  1020. }
  1021. return geo
  1022. def dict2obj(d):
  1023. if '__class__' in d and '__inst__' in d:
  1024. # For now assume all classes are Shapely geometry.
  1025. return sloads(d['__inst__'])
  1026. else:
  1027. return d
  1028. def plotg(geo):
  1029. try:
  1030. _ = iter(geo)
  1031. except:
  1032. geo = [geo]
  1033. for g in geo:
  1034. if type(g) == Polygon:
  1035. x, y = g.exterior.coords.xy
  1036. plot(x, y)
  1037. for ints in g.interiors:
  1038. x, y = ints.coords.xy
  1039. plot(x, y)
  1040. continue
  1041. if type(g) == LineString or type(g) == LinearRing:
  1042. x, y = g.coords.xy
  1043. plot(x, y)
  1044. continue
  1045. if type(g) == Point:
  1046. x, y = g.coords.xy
  1047. plot(x, y, 'o')
  1048. continue
  1049. try:
  1050. _ = iter(g)
  1051. plotg(g)
  1052. except:
  1053. print "Cannot plot:", str(type(g))
  1054. continue
  1055. ############### cam.py ####################
  1056. def coord(gstr, digits, fraction):
  1057. """
  1058. Parse Gerber coordinates
  1059. """
  1060. global gerbx, gerby
  1061. xindex = gstr.find("X")
  1062. yindex = gstr.find("Y")
  1063. index = gstr.find("D")
  1064. if xindex == -1:
  1065. x = gerbx
  1066. y = int(gstr[(yindex+1):index])*(10**(-fraction))
  1067. elif yindex == -1:
  1068. y = gerby
  1069. x = int(gstr[(xindex+1):index])*(10**(-fraction))
  1070. else:
  1071. x = int(gstr[(xindex+1):yindex])*(10**(-fraction))
  1072. y = int(gstr[(yindex+1):index])*(10**(-fraction))
  1073. gerbx = x
  1074. gerby = y
  1075. return [x, y]
  1076. ################ end of cam.py #############