Common.py 34 KB

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