camlib.py 37 KB

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