Common.py 39 KB

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