Common.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933
  1. # ##########################################################
  2. # FlatCAM: 2D Post-processing for Manufacturing #
  3. # http://flatcam.org #
  4. # Author: Juan Pablo Caram (c) #
  5. # Date: 2/5/2014 #
  6. # MIT Licence #
  7. # ##########################################################
  8. # ##########################################################
  9. # File Modified (major mod): Marius Adrian Stanciu #
  10. # Date: 11/4/2019 #
  11. # ##########################################################
  12. from PyQt5 import QtCore
  13. from shapely.geometry import Polygon, Point, LineString
  14. from shapely.ops import unary_union
  15. from appGUI.VisPyVisuals import ShapeCollection
  16. from appTool import AppTool
  17. from copy import deepcopy
  18. import collections
  19. import numpy as np
  20. import gettext
  21. import appTranslation as fcTranslate
  22. import builtins
  23. fcTranslate.apply_language('strings')
  24. if '_' not in builtins.__dict__:
  25. _ = gettext.gettext
  26. class GracefulException(Exception):
  27. """
  28. Graceful Exception raised when the user is requesting to cancel the current threaded task
  29. """
  30. def __init__(self):
  31. super().__init__()
  32. def __str__(self):
  33. return '\n\n%s' % _("The user requested a graceful exit of the current task.")
  34. class LoudDict(dict):
  35. """
  36. A Dictionary with a callback for item changes.
  37. """
  38. def __init__(self, *args, **kwargs):
  39. dict.__init__(self, *args, **kwargs)
  40. self.callback = lambda x: None
  41. def __setitem__(self, key, value):
  42. """
  43. Overridden __setitem__ method. Will emit 'changed(QString)' if the item was changed, with key as parameter.
  44. """
  45. if key in self and self.__getitem__(key) == value:
  46. return
  47. dict.__setitem__(self, key, value)
  48. self.callback(key)
  49. def update(self, *args, **kwargs):
  50. if len(args) > 1:
  51. raise TypeError("update expected at most 1 arguments, got %d" % len(args))
  52. other = dict(*args, **kwargs)
  53. for key in other:
  54. self[key] = other[key]
  55. def set_change_callback(self, callback):
  56. """
  57. Assigns a function as callback on item change. The callback
  58. will receive the key of the object that was changed.
  59. :param callback: Function to call on item change.
  60. :type callback: func
  61. :return: None
  62. """
  63. self.callback = callback
  64. class LoudUniqueList(list, collections.MutableSequence):
  65. """
  66. A List with a callback for item changes, callback which returns the index where the items are added/modified.
  67. A List that will allow adding only items that are not in the list.
  68. """
  69. def __init__(self, arg=None):
  70. super().__init__()
  71. self.callback = lambda x: None
  72. if not arg is None:
  73. if isinstance(arg, list):
  74. self.extend(arg)
  75. else:
  76. self.extend([arg])
  77. def insert(self, i, v):
  78. if v in self:
  79. raise ValueError("One of the added items is already in the list.")
  80. self.callback(i)
  81. return super().insert(i, v)
  82. def append(self, v):
  83. if v in self:
  84. raise ValueError("One of the added items is already in the list.")
  85. l = len(self)
  86. self.callback(l)
  87. return super().append(v)
  88. def extend(self, t):
  89. for v in t:
  90. if v in self:
  91. raise ValueError("One of the added items is already in the list.")
  92. l = len(self)
  93. self.callback(l)
  94. return super().extend(t)
  95. def __add__(self, t): # This is for something like `LoudUniqueList([1, 2, 3]) + list([4, 5, 6])`...
  96. for v in t:
  97. if v in self:
  98. raise ValueError("One of the added items is already in the list.")
  99. l = len(self)
  100. self.callback(l)
  101. return super().__add__(t)
  102. def __iadd__(self, t): # This is for something like `l = LoudUniqueList(); l += [1, 2, 3]`
  103. for v in t:
  104. if v in self:
  105. raise ValueError("One of the added items is already in the list.")
  106. l = len(self)
  107. self.callback(l)
  108. return super().__iadd__(t)
  109. def __setitem__(self, i, v):
  110. try:
  111. for v1 in v:
  112. if v1 in self:
  113. raise ValueError("One of the modified items is already in the list.")
  114. except TypeError:
  115. if v in self:
  116. raise ValueError("One of the modified items is already in the list.")
  117. if not v is None:
  118. self.callback(i)
  119. return super().__setitem__(i, v)
  120. def set_callback(self, callback):
  121. """
  122. Assigns a function as callback on item change. The callback
  123. will receive the index of the object that was changed.
  124. :param callback: Function to call on item change.
  125. :type callback: func
  126. :return: None
  127. """
  128. self.callback = callback
  129. class FCSignal:
  130. """
  131. Taken from here: https://blog.abstractfactory.io/dynamic-signals-in-pyqt/
  132. """
  133. def __init__(self):
  134. self.__subscribers = []
  135. def emit(self, *args, **kwargs):
  136. for subs in self.__subscribers:
  137. subs(*args, **kwargs)
  138. def connect(self, func):
  139. self.__subscribers.append(func)
  140. def disconnect(self, func):
  141. try:
  142. self.__subscribers.remove(func)
  143. except ValueError:
  144. print('Warning: function %s not removed '
  145. 'from signal %s' % (func, self))
  146. def color_variant(hex_color, bright_factor=1):
  147. """
  148. Takes a color in HEX format #FF00FF and produces a lighter or darker variant
  149. :param hex_color: color to change
  150. :type hex_color: str
  151. :param bright_factor: factor to change the color brightness [0 ... 1]
  152. :type bright_factor: float
  153. :return: Modified color
  154. :rtype: str
  155. """
  156. if len(hex_color) != 7:
  157. print("Color is %s, but needs to be in #FF00FF format. Returning original color." % hex_color)
  158. return hex_color
  159. if bright_factor > 1.0:
  160. bright_factor = 1.0
  161. if bright_factor < 0.0:
  162. bright_factor = 0.0
  163. rgb_hex = [hex_color[x:x + 2] for x in [1, 3, 5]]
  164. new_rgb = []
  165. for hex_value in rgb_hex:
  166. # adjust each color channel and turn it into a INT suitable as argument for hex()
  167. mod_color = round(int(hex_value, 16) * bright_factor)
  168. # make sure that each color channel has two digits without the 0x prefix
  169. mod_color_hex = str(hex(mod_color)[2:]).zfill(2)
  170. new_rgb.append(mod_color_hex)
  171. return "#" + "".join([i for i in new_rgb])
  172. class ExclusionAreas(QtCore.QObject):
  173. """
  174. Functionality for adding Exclusion Areas for the Excellon and Geometry FlatCAM Objects
  175. """
  176. e_shape_modified = QtCore.pyqtSignal()
  177. def __init__(self, app):
  178. super().__init__()
  179. self.app = app
  180. # Storage for shapes, storage that can be used by FlatCAm tools for utility geometry
  181. # VisPy visuals
  182. if self.app.is_legacy is False:
  183. try:
  184. self.exclusion_shapes = ShapeCollection(parent=self.app.plotcanvas.view.scene, layers=1)
  185. except AttributeError:
  186. self.exclusion_shapes = None
  187. else:
  188. from appGUI.PlotCanvasLegacy import ShapeCollectionLegacy
  189. self.exclusion_shapes = ShapeCollectionLegacy(obj=self, app=self.app, name="exclusion")
  190. # Event signals disconnect id holders
  191. self.mr = None
  192. self.mm = None
  193. self.kp = None
  194. # variables to be used in area exclusion
  195. self.cursor_pos = (0, 0)
  196. self.first_click = False
  197. self.points = []
  198. self.poly_drawn = False
  199. '''
  200. Here we store the exclusion shapes and some other information's
  201. Each list element is a dictionary with the format:
  202. {
  203. "obj_type": string ("excellon" or "geometry") <- self.obj_type
  204. "shape": Shapely polygon
  205. "strategy": string ("over" or "around") <- self.strategy_button
  206. "overz": float <- self.over_z_button
  207. }
  208. '''
  209. self.exclusion_areas_storage = []
  210. self.mouse_is_dragging = False
  211. self.solid_geometry = []
  212. self.obj_type = None
  213. self.shape_type_button = None
  214. self.over_z_button = None
  215. self.strategy_button = None
  216. self.cnc_button = None
  217. def on_add_area_click(self, shape_button, overz_button, strategy_radio, cnc_button, solid_geo, obj_type):
  218. """
  219. :param shape_button: a FCButton that has the value for the shape
  220. :param overz_button: a FCDoubleSpinner that holds the Over Z value
  221. :param strategy_radio: a RadioSet button with the strategy_button value
  222. :param cnc_button: a FCButton in Object UI that when clicked the CNCJob is created
  223. We have a reference here so we can change the color signifying that exclusion areas are
  224. available.
  225. :param solid_geo: reference to the object solid geometry for which we add exclusion areas
  226. :param obj_type: Type of FlatCAM object that called this method. String: "excellon" or "geometry"
  227. :type obj_type: str
  228. :return: None
  229. """
  230. self.app.inform.emit('[WARNING_NOTCL] %s' % _("Click the start point of the area."))
  231. self.app.call_source = 'geometry'
  232. self.shape_type_button = shape_button
  233. self.over_z_button = overz_button
  234. self.strategy_button = strategy_radio
  235. self.cnc_button = cnc_button
  236. self.solid_geometry = solid_geo
  237. self.obj_type = obj_type
  238. if self.app.is_legacy is False:
  239. self.app.plotcanvas.graph_event_disconnect('mouse_press', self.app.on_mouse_click_over_plot)
  240. self.app.plotcanvas.graph_event_disconnect('mouse_move', self.app.on_mouse_move_over_plot)
  241. self.app.plotcanvas.graph_event_disconnect('mouse_release', self.app.on_mouse_click_release_over_plot)
  242. else:
  243. self.app.plotcanvas.graph_event_disconnect(self.app.mp)
  244. self.app.plotcanvas.graph_event_disconnect(self.app.mm)
  245. self.app.plotcanvas.graph_event_disconnect(self.app.mr)
  246. self.mr = self.app.plotcanvas.graph_event_connect('mouse_release', self.on_mouse_release)
  247. self.mm = self.app.plotcanvas.graph_event_connect('mouse_move', self.on_mouse_move)
  248. # self.kp = self.app.plotcanvas.graph_event_connect('key_press', self.on_key_press)
  249. # To be called after clicking on the plot.
  250. def on_mouse_release(self, event):
  251. """
  252. Called on mouse click release.
  253. :param event: Mouse event
  254. :type event:
  255. :return: None
  256. :rtype:
  257. """
  258. if self.app.is_legacy is False:
  259. event_pos = event.pos
  260. # event_is_dragging = event.is_dragging
  261. right_button = 2
  262. else:
  263. event_pos = (event.xdata, event.ydata)
  264. # event_is_dragging = self.app.plotcanvas.is_dragging
  265. right_button = 3
  266. event_pos = self.app.plotcanvas.translate_coords(event_pos)
  267. if self.app.grid_status():
  268. curr_pos = self.app.geo_editor.snap(event_pos[0], event_pos[1])
  269. else:
  270. curr_pos = (event_pos[0], event_pos[1])
  271. x1, y1 = curr_pos[0], curr_pos[1]
  272. # shape_type_button = self.ui.area_shape_radio.get_value()
  273. # do clear area only for left mouse clicks
  274. if event.button == 1:
  275. if self.shape_type_button.get_value() == "square":
  276. if self.first_click is False:
  277. self.first_click = True
  278. self.app.inform.emit('[WARNING_NOTCL] %s' % _("Click the end point of the area."))
  279. self.cursor_pos = self.app.plotcanvas.translate_coords(event_pos)
  280. if self.app.grid_status():
  281. self.cursor_pos = self.app.geo_editor.snap(event_pos[0], event_pos[1])
  282. else:
  283. self.app.inform.emit(_("Zone added. Click to start adding next zone or right click to finish."))
  284. self.app.delete_selection_shape()
  285. x0, y0 = self.cursor_pos[0], self.cursor_pos[1]
  286. pt1 = (x0, y0)
  287. pt2 = (x1, y0)
  288. pt3 = (x1, y1)
  289. pt4 = (x0, y1)
  290. new_rectangle = Polygon([pt1, pt2, pt3, pt4])
  291. # {
  292. # "obj_type": string("excellon" or "geometry") < - self.obj_type
  293. # "shape": Shapely polygon
  294. # "strategy_button": string("over" or "around") < - self.strategy_button
  295. # "overz": float < - self.over_z_button
  296. # }
  297. new_el = {
  298. "obj_type": self.obj_type,
  299. "shape": new_rectangle,
  300. "strategy": self.strategy_button.get_value(),
  301. "overz": self.over_z_button.get_value()
  302. }
  303. self.exclusion_areas_storage.append(new_el)
  304. if self.obj_type == 'excellon':
  305. color = "#FF7400"
  306. face_color = "#FF7400BF"
  307. else:
  308. color = "#098a8f"
  309. face_color = "#FF7400BF"
  310. # add a temporary shape on canvas
  311. AppTool.draw_tool_selection_shape(
  312. self, old_coords=(x0, y0), coords=(x1, y1),
  313. color=color,
  314. face_color=face_color,
  315. shapes_storage=self.exclusion_shapes)
  316. self.first_click = False
  317. return
  318. else:
  319. self.points.append((x1, y1))
  320. if len(self.points) > 1:
  321. self.poly_drawn = True
  322. self.app.inform.emit(_("Click on next Point or click right mouse button to complete ..."))
  323. return ""
  324. elif event.button == right_button and self.mouse_is_dragging is False:
  325. shape_type = self.shape_type_button.get_value()
  326. if shape_type == "square":
  327. self.first_click = False
  328. else:
  329. # if we finish to add a polygon
  330. if self.poly_drawn is True:
  331. try:
  332. # try to add the point where we last clicked if it is not already in the self.points
  333. last_pt = (x1, y1)
  334. if last_pt != self.points[-1]:
  335. self.points.append(last_pt)
  336. except IndexError:
  337. pass
  338. # we need to add a Polygon and a Polygon can be made only from at least 3 points
  339. if len(self.points) > 2:
  340. AppTool.delete_moving_selection_shape(self)
  341. pol = Polygon(self.points)
  342. # do not add invalid polygons even if they are drawn by utility geometry
  343. if pol.is_valid:
  344. """
  345. {
  346. "obj_type": string("excellon" or "geometry") < - self.obj_type
  347. "shape": Shapely polygon
  348. "strategy": string("over" or "around") < - self.strategy_button
  349. "overz": float < - self.over_z_button
  350. }
  351. """
  352. new_el = {
  353. "obj_type": self.obj_type,
  354. "shape": pol,
  355. "strategy": self.strategy_button.get_value(),
  356. "overz": self.over_z_button.get_value()
  357. }
  358. self.exclusion_areas_storage.append(new_el)
  359. if self.obj_type == 'excellon':
  360. color = "#FF7400"
  361. face_color = "#FF7400BF"
  362. else:
  363. color = "#098a8f"
  364. face_color = "#FF7400BF"
  365. AppTool.draw_selection_shape_polygon(
  366. self, points=self.points,
  367. color=color,
  368. face_color=face_color,
  369. shapes_storage=self.exclusion_shapes)
  370. self.app.inform.emit(
  371. _("Zone added. Click to start adding next zone or right click to finish."))
  372. self.points = []
  373. self.poly_drawn = False
  374. return
  375. # AppTool.delete_tool_selection_shape(self, shapes_storage=self.exclusion_shapes)
  376. if self.app.is_legacy is False:
  377. self.app.plotcanvas.graph_event_disconnect('mouse_release', self.on_mouse_release)
  378. self.app.plotcanvas.graph_event_disconnect('mouse_move', self.on_mouse_move)
  379. # self.app.plotcanvas.graph_event_disconnect('key_press', self.on_key_press)
  380. else:
  381. self.app.plotcanvas.graph_event_disconnect(self.mr)
  382. self.app.plotcanvas.graph_event_disconnect(self.mm)
  383. # self.app.plotcanvas.graph_event_disconnect(self.kp)
  384. self.app.mp = self.app.plotcanvas.graph_event_connect('mouse_press',
  385. self.app.on_mouse_click_over_plot)
  386. self.app.mm = self.app.plotcanvas.graph_event_connect('mouse_move',
  387. self.app.on_mouse_move_over_plot)
  388. self.app.mr = self.app.plotcanvas.graph_event_connect('mouse_release',
  389. self.app.on_mouse_click_release_over_plot)
  390. self.app.call_source = 'app'
  391. if len(self.exclusion_areas_storage) == 0:
  392. return
  393. # since the exclusion areas should apply to all objects in the app collection, this check is limited to
  394. # only the current object therefore it will not guarantee success
  395. self.app.inform.emit("%s" % _("Exclusion areas added. Checking overlap with the object geometry ..."))
  396. for el in self.exclusion_areas_storage:
  397. if el["shape"].intersects(unary_union(self.solid_geometry)):
  398. self.on_clear_area_click()
  399. self.app.inform.emit(
  400. "[ERROR_NOTCL] %s" % _("Failed. Exclusion areas intersects the object geometry ..."))
  401. return
  402. self.app.inform.emit("[success] %s" % _("Exclusion areas added."))
  403. self.cnc_button.setStyleSheet("""
  404. QPushButton
  405. {
  406. font-weight: bold;
  407. color: orange;
  408. }
  409. """)
  410. self.cnc_button.setToolTip(
  411. '%s %s' % (_("Generate the CNC Job object."), _("With Exclusion areas."))
  412. )
  413. self.e_shape_modified.emit()
  414. def area_disconnect(self):
  415. """
  416. Will do the cleanup. Will disconnect the mouse events for the custom handlers in this class and initialize
  417. certain class attributes.
  418. :return: None
  419. :rtype:
  420. """
  421. if self.app.is_legacy is False:
  422. self.app.plotcanvas.graph_event_disconnect('mouse_release', self.on_mouse_release)
  423. self.app.plotcanvas.graph_event_disconnect('mouse_move', self.on_mouse_move)
  424. else:
  425. self.app.plotcanvas.graph_event_disconnect(self.mr)
  426. self.app.plotcanvas.graph_event_disconnect(self.mm)
  427. self.app.plotcanvas.graph_event_disconnect(self.kp)
  428. self.app.mp = self.app.plotcanvas.graph_event_connect('mouse_press',
  429. self.app.on_mouse_click_over_plot)
  430. self.app.mm = self.app.plotcanvas.graph_event_connect('mouse_move',
  431. self.app.on_mouse_move_over_plot)
  432. self.app.mr = self.app.plotcanvas.graph_event_connect('mouse_release',
  433. self.app.on_mouse_click_release_over_plot)
  434. self.points = []
  435. self.poly_drawn = False
  436. self.exclusion_areas_storage = []
  437. AppTool.delete_moving_selection_shape(self)
  438. # AppTool.delete_tool_selection_shape(self, shapes_storage=self.exclusion_shapes)
  439. self.app.call_source = "app"
  440. self.app.inform.emit("[WARNING_NOTCL] %s" % _("Cancelled. Area exclusion drawing was interrupted."))
  441. def on_mouse_move(self, event):
  442. """
  443. Called on mouse move
  444. :param event: mouse event
  445. :type event:
  446. :return: None
  447. :rtype:
  448. """
  449. shape_type = self.shape_type_button.get_value()
  450. if self.app.is_legacy is False:
  451. event_pos = event.pos
  452. event_is_dragging = event.is_dragging
  453. # right_button = 2
  454. else:
  455. event_pos = (event.xdata, event.ydata)
  456. event_is_dragging = self.app.plotcanvas.is_dragging
  457. # right_button = 3
  458. curr_pos = self.app.plotcanvas.translate_coords(event_pos)
  459. # detect mouse dragging motion
  460. if event_is_dragging is True:
  461. self.mouse_is_dragging = True
  462. else:
  463. self.mouse_is_dragging = False
  464. # update the cursor position
  465. if self.app.grid_status():
  466. # Update cursor
  467. curr_pos = self.app.geo_editor.snap(curr_pos[0], curr_pos[1])
  468. self.app.app_cursor.set_data(np.asarray([(curr_pos[0], curr_pos[1])]),
  469. symbol='++', edge_color=self.app.cursor_color_3D,
  470. edge_width=self.app.defaults["global_cursor_width"],
  471. size=self.app.defaults["global_cursor_size"])
  472. # update the positions on status bar
  473. if self.cursor_pos is None:
  474. self.cursor_pos = (0, 0)
  475. self.app.dx = curr_pos[0] - float(self.cursor_pos[0])
  476. self.app.dy = curr_pos[1] - float(self.cursor_pos[1])
  477. self.app.ui.position_label.setText("&nbsp;<b>X</b>: %.4f&nbsp;&nbsp; "
  478. "<b>Y</b>: %.4f&nbsp;" % (curr_pos[0], curr_pos[1]))
  479. self.app.ui.rel_position_label.setText("<b>Dx</b>: %.4f&nbsp;&nbsp; <b>Dy</b>: "
  480. "%.4f&nbsp;&nbsp;&nbsp;&nbsp;" % (self.app.dx, self.app.dy))
  481. units = self.app.defaults["units"].lower()
  482. self.app.plotcanvas.text_hud.text = \
  483. 'Dx:\t{:<.4f} [{:s}]\nDy:\t{:<.4f} [{:s}]\n\nX: \t{:<.4f} [{:s}]\nY: \t{:<.4f} [{:s}]'.format(
  484. self.app.dx, units, self.app.dy, units, curr_pos[0], units, curr_pos[1], units)
  485. if self.obj_type == 'excellon':
  486. color = "#FF7400"
  487. face_color = "#FF7400BF"
  488. else:
  489. color = "#098a8f"
  490. face_color = "#FF7400BF"
  491. # draw the utility geometry
  492. if shape_type == "square":
  493. if self.first_click:
  494. self.app.delete_selection_shape()
  495. self.app.draw_moving_selection_shape(old_coords=(self.cursor_pos[0], self.cursor_pos[1]),
  496. color=color,
  497. face_color=face_color,
  498. coords=(curr_pos[0], curr_pos[1]))
  499. else:
  500. AppTool.delete_moving_selection_shape(self)
  501. AppTool.draw_moving_selection_shape_poly(
  502. self, points=self.points,
  503. color=color,
  504. face_color=face_color,
  505. data=(curr_pos[0], curr_pos[1]))
  506. def on_clear_area_click(self):
  507. """
  508. Slot for clicking the button for Deleting all the Exclusion areas.
  509. :return: None
  510. :rtype:
  511. """
  512. self.clear_shapes()
  513. # restore the default StyleSheet
  514. self.cnc_button.setStyleSheet("")
  515. # update the StyleSheet
  516. self.cnc_button.setStyleSheet("""
  517. QPushButton
  518. {
  519. font-weight: bold;
  520. }
  521. """)
  522. self.cnc_button.setToolTip('%s' % _("Generate the CNC Job object."))
  523. def clear_shapes(self):
  524. """
  525. Will delete all the Exclusion areas; will delete on canvas any possible selection box for the Exclusion areas.
  526. :return: None
  527. :rtype:
  528. """
  529. if self.exclusion_areas_storage:
  530. self.app.inform.emit('%s' % _("All exclusion zones deleted."))
  531. self.exclusion_areas_storage.clear()
  532. AppTool.delete_moving_selection_shape(self)
  533. self.app.delete_selection_shape()
  534. AppTool.delete_tool_selection_shape(self, shapes_storage=self.exclusion_shapes)
  535. def delete_sel_shapes(self, idxs):
  536. """
  537. :param idxs: list of indexes in self.exclusion_areas_storage list to be deleted
  538. :type idxs: list
  539. :return: None
  540. """
  541. # delete all plotted shapes
  542. AppTool.delete_tool_selection_shape(self, shapes_storage=self.exclusion_shapes)
  543. # delete shapes
  544. for idx in sorted(idxs, reverse=True):
  545. del self.exclusion_areas_storage[idx]
  546. # re-add what's left after deletion in first step
  547. if self.obj_type == 'excellon':
  548. color = "#FF7400"
  549. face_color = "#FF7400BF"
  550. else:
  551. color = "#098a8f"
  552. face_color = "#FF7400BF"
  553. face_alpha = 0.3
  554. color_t = face_color[:-2] + str(hex(int(face_alpha * 255)))[2:]
  555. for geo_el in self.exclusion_areas_storage:
  556. if isinstance(geo_el['shape'], Polygon):
  557. self.exclusion_shapes.add(
  558. geo_el['shape'], color=color, face_color=color_t, update=True, layer=0, tolerance=None)
  559. if self.app.is_legacy is True:
  560. self.exclusion_shapes.redraw()
  561. # if there are still some exclusion areas in the storage
  562. if self.exclusion_areas_storage:
  563. self.app.inform.emit('[success] %s' % _("Selected exclusion zones deleted."))
  564. else:
  565. # restore the default StyleSheet
  566. self.cnc_button.setStyleSheet("")
  567. # update the StyleSheet
  568. self.cnc_button.setStyleSheet("""
  569. QPushButton
  570. {
  571. font-weight: bold;
  572. }
  573. """)
  574. self.cnc_button.setToolTip('%s' % _("Generate the CNC Job object."))
  575. # there are no more exclusion areas in the storage, all have been selected and deleted
  576. self.app.inform.emit('%s' % _("All exclusion zones deleted."))
  577. def travel_coordinates(self, start_point, end_point, tooldia):
  578. """
  579. WIll create a path the go around the exclusion areas on the shortest path when travelling (at a Z above the
  580. material).
  581. :param start_point: X,Y coordinates for the start point of the travel line
  582. :type start_point: tuple
  583. :param end_point: X,Y coordinates for the destination point of the travel line
  584. :type end_point: tuple
  585. :param tooldia: THe tool diameter used and which generates the travel lines
  586. :type tooldia float
  587. :return: A list of x,y tuples that describe the avoiding path
  588. :rtype: list
  589. """
  590. ret_list = []
  591. # Travel lines: rapids. Should not pass through Exclusion areas
  592. travel_line = LineString([start_point, end_point])
  593. origin_point = Point(start_point)
  594. buffered_storage = []
  595. # add a little something to the half diameter, to make sure that we really don't enter in the exclusion zones
  596. buffered_distance = (tooldia / 2.0) + (0.1 if self.app.defaults['units'] == 'MM' else 0.00393701)
  597. for area in self.exclusion_areas_storage:
  598. new_area = deepcopy(area)
  599. new_area['shape'] = area['shape'].buffer(buffered_distance, join_style=2)
  600. buffered_storage.append(new_area)
  601. # sort the Exclusion areas from the closest to the start_point to the farthest
  602. tmp = []
  603. for area in buffered_storage:
  604. dist = Point(start_point).distance(area['shape'])
  605. tmp.append((dist, area))
  606. tmp.sort(key=lambda k: k[0])
  607. sorted_area_storage = [k[1] for k in tmp]
  608. # process the ordered exclusion areas list
  609. for area in sorted_area_storage:
  610. outline = area['shape'].exterior
  611. if travel_line.intersects(outline):
  612. intersection_pts = travel_line.intersection(outline)
  613. if isinstance(intersection_pts, Point):
  614. # it's just a touch, continue
  615. continue
  616. entry_pt = nearest_point(origin_point, intersection_pts)
  617. exit_pt = farthest_point(origin_point, intersection_pts)
  618. if area['strategy'] == 'around':
  619. full_vertex_points = [Point(x) for x in list(outline.coords)]
  620. # the last coordinate in outline, a LinearRing, is the closing one
  621. # therefore a duplicate of the first one; discard it
  622. vertex_points = full_vertex_points[:-1]
  623. # dist_from_entry = [(entry_pt.distance(vt), vertex_points.index(vt)) for vt in vertex_points]
  624. # closest_point_entry = nsmallest(1, dist_from_entry, key=lambda x: x[0])
  625. # start_idx = closest_point_entry[0][1]
  626. #
  627. # dist_from_exit = [(exit_pt.distance(vt), vertex_points.index(vt)) for vt in vertex_points]
  628. # closest_point_exit = nsmallest(1, dist_from_exit, key=lambda x: x[0])
  629. # end_idx = closest_point_exit[0][1]
  630. # pts_line_entry = None
  631. # pts_line_exit = None
  632. # for i in range(len(full_vertex_points)):
  633. # try:
  634. # line = LineString(
  635. # [
  636. # (full_vertex_points[i].x, full_vertex_points[i].y),
  637. # (full_vertex_points[i + 1].x, full_vertex_points[i + 1].y)
  638. # ]
  639. # )
  640. # except IndexError:
  641. # continue
  642. #
  643. # if entry_pt.within(line) or entry_pt.equals(Point(line.coords[0])) or \
  644. # entry_pt.equals(Point(line.coords[1])):
  645. # pts_line_entry = [Point(x) for x in line.coords]
  646. #
  647. # if exit_pt.within(line) or exit_pt.equals(Point(line.coords[0])) or \
  648. # exit_pt.equals(Point(line.coords[1])):
  649. # pts_line_exit = [Point(x) for x in line.coords]
  650. #
  651. # closest_point_entry = nearest_point(entry_pt, pts_line_entry)
  652. # start_idx = vertex_points.index(closest_point_entry)
  653. #
  654. # closest_point_exit = nearest_point(exit_pt, pts_line_exit)
  655. # end_idx = vertex_points.index(closest_point_exit)
  656. # find all vertexes for which a line from start_point does not cross the Exclusion area polygon
  657. # the same for end_point
  658. # we don't need closest points for which the path leads to crosses of the Exclusion area
  659. close_start_points = []
  660. close_end_points = []
  661. for i in range(len(vertex_points)):
  662. try:
  663. start_line = LineString(
  664. [
  665. start_point,
  666. (vertex_points[i].x, vertex_points[i].y)
  667. ]
  668. )
  669. end_line = LineString(
  670. [
  671. end_point,
  672. (vertex_points[i].x, vertex_points[i].y)
  673. ]
  674. )
  675. except IndexError:
  676. continue
  677. if not start_line.crosses(area['shape']):
  678. close_start_points.append(vertex_points[i])
  679. if not end_line.crosses(area['shape']):
  680. close_end_points.append(vertex_points[i])
  681. closest_point_entry = nearest_point(entry_pt, close_start_points)
  682. closest_point_exit = nearest_point(exit_pt, close_end_points)
  683. start_idx = vertex_points.index(closest_point_entry)
  684. end_idx = vertex_points.index(closest_point_exit)
  685. # calculate possible paths: one clockwise the other counterclockwise on the exterior of the
  686. # exclusion area outline (Polygon.exterior)
  687. vp_len = len(vertex_points)
  688. if end_idx > start_idx:
  689. path_1 = vertex_points[start_idx:(end_idx + 1)]
  690. path_2 = [vertex_points[start_idx]]
  691. idx = start_idx
  692. for __ in range(vp_len):
  693. idx = idx - 1 if idx > 0 else (vp_len - 1)
  694. path_2.append(vertex_points[idx])
  695. if idx == end_idx:
  696. break
  697. else:
  698. path_1 = vertex_points[end_idx:(start_idx + 1)]
  699. path_2 = [vertex_points[end_idx]]
  700. idx = end_idx
  701. for __ in range(vp_len):
  702. idx = idx - 1 if idx > 0 else (vp_len - 1)
  703. path_2.append(vertex_points[idx])
  704. if idx == start_idx:
  705. break
  706. path_1.reverse()
  707. path_2.reverse()
  708. # choose the one with the lesser length
  709. length_path_1 = 0
  710. for i in range(len(path_1)):
  711. try:
  712. length_path_1 += path_1[i].distance(path_1[i + 1])
  713. except IndexError:
  714. pass
  715. length_path_2 = 0
  716. for i in range(len(path_2)):
  717. try:
  718. length_path_2 += path_2[i].distance(path_2[i + 1])
  719. except IndexError:
  720. pass
  721. path = path_1 if length_path_1 < length_path_2 else path_2
  722. # transform the list of Points into a list of Points coordinates
  723. path_coords = [[None, (p.x, p.y)] for p in path]
  724. ret_list += path_coords
  725. else:
  726. path_coords = [[float(area['overz']), (entry_pt.x, entry_pt.y)], [None, (exit_pt.x, exit_pt.y)]]
  727. ret_list += path_coords
  728. # create a new LineString to test again for possible other Exclusion zones
  729. last_pt_in_path = path_coords[-1][1]
  730. travel_line = LineString([last_pt_in_path, end_point])
  731. ret_list.append([None, end_point])
  732. return ret_list
  733. def farthest_point(origin, points_list):
  734. """
  735. Calculate the farthest Point in a list from another Point
  736. :param origin: Reference Point
  737. :type origin: Point
  738. :param points_list: List of Points or a MultiPoint
  739. :type points_list: list
  740. :return: Farthest Point
  741. :rtype: Point
  742. """
  743. old_dist = 0
  744. fartherst_pt = None
  745. for pt in points_list:
  746. dist = abs(origin.distance(pt))
  747. if dist >= old_dist:
  748. fartherst_pt = pt
  749. old_dist = dist
  750. return fartherst_pt
  751. def nearest_point(origin, points_list):
  752. """
  753. Calculate the nearest Point in a list from another Point
  754. :param origin: Reference Point
  755. :type origin: Point
  756. :param points_list: List of Points or a MultiPoint
  757. :type points_list: list
  758. :return: Nearest Point
  759. :rtype: Point
  760. """
  761. old_dist = np.Inf
  762. nearest_pt = None
  763. for pt in points_list:
  764. dist = abs(origin.distance(pt))
  765. if dist <= old_dist:
  766. nearest_pt = pt
  767. old_dist = dist
  768. return nearest_pt