camlib.py 40 KB

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