Common.py 34 KB

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