Common.py 39 KB

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