camlib.py 40 KB

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