ToolAlignObjects.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. # ##########################################################
  2. # FlatCAM: 2D Post-processing for Manufacturing #
  3. # File Author: Marius Adrian Stanciu (c) #
  4. # Date: 1/13/2020 #
  5. # MIT Licence #
  6. # ##########################################################
  7. from PyQt5 import QtWidgets, QtCore, QtGui
  8. from appTool import AppTool
  9. from appGUI.GUIElements import FCComboBox, RadioSet, FCLabel, FCButton
  10. import math
  11. from shapely.geometry import Point
  12. from shapely.affinity import translate
  13. import gettext
  14. import appTranslation as fcTranslate
  15. import builtins
  16. import logging
  17. fcTranslate.apply_language('strings')
  18. if '_' not in builtins.__dict__:
  19. _ = gettext.gettext
  20. log = logging.getLogger('base')
  21. class AlignObjects(AppTool):
  22. toolName = _("Align Objects")
  23. def __init__(self, app):
  24. AppTool.__init__(self, app)
  25. self.app = app
  26. self.decimals = app.decimals
  27. self.canvas = self.app.plotcanvas
  28. # #############################################################################
  29. # ######################### Tool GUI ##########################################
  30. # #############################################################################
  31. self.ui = AlignUI(layout=self.layout, app=self.app)
  32. self.toolName = self.ui.toolName
  33. # Signals
  34. self.ui.align_object_button.clicked.connect(self.on_align)
  35. self.ui.type_obj_radio.activated_custom.connect(self.on_type_obj_changed)
  36. self.ui.type_aligner_obj_radio.activated_custom.connect(self.on_type_aligner_changed)
  37. self.ui.reset_button.clicked.connect(self.set_tool_ui)
  38. self.mr = None
  39. # if the mouse events are connected to a local method set this True
  40. self.local_connected = False
  41. # store the status of the grid
  42. self.grid_status_memory = None
  43. self.aligned_obj = None
  44. self.aligner_obj = None
  45. # this is one of the objects: self.aligned_obj or self.aligner_obj
  46. self.target_obj = None
  47. # here store the alignment points
  48. self.clicked_points = []
  49. self.align_type = None
  50. # old colors of objects involved in the alignment
  51. self.aligner_old_fill_color = None
  52. self.aligner_old_line_color = None
  53. self.aligned_old_fill_color = None
  54. self.aligned_old_line_color = None
  55. def run(self, toggle=True):
  56. self.app.defaults.report_usage("ToolAlignObjects()")
  57. if toggle:
  58. # if the splitter is hidden, display it, else hide it but only if the current widget is the same
  59. if self.app.ui.splitter.sizes()[0] == 0:
  60. self.app.ui.splitter.setSizes([1, 1])
  61. else:
  62. try:
  63. if self.app.ui.tool_scroll_area.widget().objectName() == self.toolName:
  64. # if tab is populated with the tool but it does not have the focus, focus on it
  65. if not self.app.ui.notebook.currentWidget() is self.app.ui.tool_tab:
  66. # focus on Tool Tab
  67. self.app.ui.notebook.setCurrentWidget(self.app.ui.tool_tab)
  68. else:
  69. self.app.ui.splitter.setSizes([0, 1])
  70. except AttributeError:
  71. pass
  72. else:
  73. if self.app.ui.splitter.sizes()[0] == 0:
  74. self.app.ui.splitter.setSizes([1, 1])
  75. AppTool.run(self)
  76. self.set_tool_ui()
  77. self.app.ui.notebook.setTabText(2, _("Align Tool"))
  78. def install(self, icon=None, separator=None, **kwargs):
  79. AppTool.install(self, icon, separator, shortcut='Alt+A', **kwargs)
  80. def set_tool_ui(self):
  81. self.reset_fields()
  82. self.clicked_points = []
  83. self.target_obj = None
  84. self.aligned_obj = None
  85. self.aligner_obj = None
  86. self.aligner_old_fill_color = None
  87. self.aligner_old_line_color = None
  88. self.aligned_old_fill_color = None
  89. self.aligned_old_line_color = None
  90. self.ui.a_type_radio.set_value(self.app.defaults["tools_align_objects_align_type"])
  91. self.ui.type_obj_radio.set_value('grb')
  92. self.ui.type_aligner_obj_radio.set_value('grb')
  93. if self.local_connected is True:
  94. self.disconnect_cal_events()
  95. def on_type_obj_changed(self, val):
  96. obj_type = {'grb': 0, 'exc': 1}[val]
  97. self.ui.object_combo.setRootModelIndex(self.app.collection.index(obj_type, 0, QtCore.QModelIndex()))
  98. self.ui.object_combo.setCurrentIndex(0)
  99. self.ui.object_combo.obj_type = {'grb': "Gerber", 'exc': "Excellon"}[val]
  100. def on_type_aligner_changed(self, val):
  101. obj_type = {'grb': 0, 'exc': 1}[val]
  102. self.ui.aligner_object_combo.setRootModelIndex(self.app.collection.index(obj_type, 0, QtCore.QModelIndex()))
  103. self.ui.aligner_object_combo.setCurrentIndex(0)
  104. self.ui.aligner_object_combo.obj_type = {'grb': "Gerber", 'exc': "Excellon"}[val]
  105. def on_align(self):
  106. self.app.delete_selection_shape()
  107. obj_sel_index = self.ui.object_combo.currentIndex()
  108. obj_model_index = self.app.collection.index(obj_sel_index, 0, self.ui.object_combo.rootModelIndex())
  109. try:
  110. self.aligned_obj = obj_model_index.internalPointer().obj
  111. except AttributeError:
  112. self.app.inform.emit('[WARNING_NOTCL] %s' % _("There is no aligned FlatCAM object selected..."))
  113. return
  114. aligner_obj_sel_index = self.ui.aligner_object_combo.currentIndex()
  115. aligner_obj_model_index = self.app.collection.index(
  116. aligner_obj_sel_index, 0, self.ui.aligner_object_combo.rootModelIndex())
  117. try:
  118. self.aligner_obj = aligner_obj_model_index.internalPointer().obj
  119. except AttributeError:
  120. self.app.inform.emit('[WARNING_NOTCL] %s' % _("There is no aligner FlatCAM object selected..."))
  121. return
  122. self.align_type = self.ui.a_type_radio.get_value()
  123. # disengage the grid snapping since it will be hard to find the drills or pads on grid
  124. if self.app.ui.grid_snap_btn.isChecked():
  125. self.grid_status_memory = True
  126. self.app.ui.grid_snap_btn.trigger()
  127. else:
  128. self.grid_status_memory = False
  129. self.local_connected = True
  130. self.aligner_old_fill_color = self.aligner_obj.fill_color
  131. self.aligner_old_line_color = self.aligner_obj.outline_color
  132. self.aligned_old_fill_color = self.aligned_obj.fill_color
  133. self.aligned_old_line_color = self.aligned_obj.outline_color
  134. self.target_obj = self.aligned_obj
  135. self.set_color()
  136. self.app.inform.emit('%s: %s' % (_("First Point"), _("Click on the START point.")))
  137. self.mr = self.canvas.graph_event_connect('mouse_release', self.on_mouse_click_release)
  138. if self.app.is_legacy is False:
  139. self.canvas.graph_event_disconnect('mouse_release', self.app.on_mouse_click_release_over_plot)
  140. else:
  141. self.canvas.graph_event_disconnect(self.app.mr)
  142. def on_mouse_click_release(self, event):
  143. if self.app.is_legacy is False:
  144. event_pos = event.pos
  145. right_button = 2
  146. self.app.event_is_dragging = self.app.event_is_dragging
  147. else:
  148. event_pos = (event.xdata, event.ydata)
  149. right_button = 3
  150. self.app.event_is_dragging = self.app.ui.popMenu.mouse_is_panning
  151. pos_canvas = self.canvas.translate_coords(event_pos)
  152. if event.button == 1:
  153. click_pt = Point([pos_canvas[0], pos_canvas[1]])
  154. if self.app.selection_type is not None:
  155. # delete previous selection shape
  156. self.app.delete_selection_shape()
  157. self.app.selection_type = None
  158. else:
  159. if self.target_obj.kind.lower() == 'excellon':
  160. for tool, tool_dict in self.target_obj.tools.items():
  161. for geo in tool_dict['solid_geometry']:
  162. if click_pt.within(geo):
  163. center_pt = geo.centroid
  164. self.clicked_points.append(
  165. [
  166. float('%.*f' % (self.decimals, center_pt.x)),
  167. float('%.*f' % (self.decimals, center_pt.y))
  168. ]
  169. )
  170. self.check_points()
  171. elif self.target_obj.kind.lower() == 'gerber':
  172. for apid, apid_val in self.target_obj.apertures.items():
  173. for geo_el in apid_val['geometry']:
  174. if 'solid' in geo_el:
  175. if click_pt.within(geo_el['solid']):
  176. if isinstance(geo_el['follow'], Point):
  177. center_pt = geo_el['solid'].centroid
  178. self.clicked_points.append(
  179. [
  180. float('%.*f' % (self.decimals, center_pt.x)),
  181. float('%.*f' % (self.decimals, center_pt.y))
  182. ]
  183. )
  184. self.check_points()
  185. elif event.button == right_button and self.app.event_is_dragging is False:
  186. self.reset_color()
  187. self.clicked_points = []
  188. self.disconnect_cal_events()
  189. self.app.inform.emit('[WARNING_NOTCL] %s' % _("Cancelled by user request."))
  190. def check_points(self):
  191. if len(self.clicked_points) == 1:
  192. self.app.inform.emit('%s: %s. %s' % (
  193. _("First Point"), _("Click on the DESTINATION point."), _("Or right click to cancel.")))
  194. self.target_obj = self.aligner_obj
  195. self.reset_color()
  196. self.set_color()
  197. if len(self.clicked_points) == 2:
  198. if self.align_type == 'sp':
  199. self.align_translate()
  200. self.app.inform.emit('[success] %s' % _("Done."))
  201. self.app.plot_all()
  202. self.disconnect_cal_events()
  203. return
  204. else:
  205. self.app.inform.emit('%s: %s. %s' % (
  206. _("Second Point"), _("Click on the START point."), _("Or right click to cancel.")))
  207. self.target_obj = self.aligned_obj
  208. self.reset_color()
  209. self.set_color()
  210. if len(self.clicked_points) == 3:
  211. self.app.inform.emit('%s: %s. %s' % (
  212. _("Second Point"), _("Click on the DESTINATION point."), _("Or right click to cancel.")))
  213. self.target_obj = self.aligner_obj
  214. self.reset_color()
  215. self.set_color()
  216. if len(self.clicked_points) == 4:
  217. self.align_translate()
  218. self.align_rotate()
  219. self.app.inform.emit('[success] %s' % _("Done."))
  220. self.disconnect_cal_events()
  221. self.app.plot_all()
  222. def align_translate(self):
  223. dx = self.clicked_points[1][0] - self.clicked_points[0][0]
  224. dy = self.clicked_points[1][1] - self.clicked_points[0][1]
  225. self.aligned_obj.offset((dx, dy))
  226. # Update the object bounding box options
  227. a, b, c, d = self.aligned_obj.bounds()
  228. self.aligned_obj.options['xmin'] = a
  229. self.aligned_obj.options['ymin'] = b
  230. self.aligned_obj.options['xmax'] = c
  231. self.aligned_obj.options['ymax'] = d
  232. def align_rotate(self):
  233. dx = self.clicked_points[1][0] - self.clicked_points[0][0]
  234. dy = self.clicked_points[1][1] - self.clicked_points[0][1]
  235. test_rotation_pt = translate(Point(self.clicked_points[2]), xoff=dx, yoff=dy)
  236. new_start = (test_rotation_pt.x, test_rotation_pt.y)
  237. new_dest = self.clicked_points[3]
  238. origin_pt = self.clicked_points[1]
  239. dxd = new_dest[0] - origin_pt[0]
  240. dyd = new_dest[1] - origin_pt[1]
  241. dxs = new_start[0] - origin_pt[0]
  242. dys = new_start[1] - origin_pt[1]
  243. rotation_not_needed = (abs(new_start[0] - new_dest[0]) <= (10 ** -self.decimals)) or \
  244. (abs(new_start[1] - new_dest[1]) <= (10 ** -self.decimals))
  245. if rotation_not_needed is False:
  246. # calculate rotation angle
  247. angle_dest = math.degrees(math.atan(dyd / dxd))
  248. angle_start = math.degrees(math.atan(dys / dxs))
  249. angle = angle_dest - angle_start
  250. self.aligned_obj.rotate(angle=angle, point=origin_pt)
  251. def disconnect_cal_events(self):
  252. # restore the Grid snapping if it was active before
  253. if self.grid_status_memory is True:
  254. self.app.ui.grid_snap_btn.trigger()
  255. self.app.mr = self.canvas.graph_event_connect('mouse_release', self.app.on_mouse_click_release_over_plot)
  256. if self.app.is_legacy is False:
  257. self.canvas.graph_event_disconnect('mouse_release', self.on_mouse_click_release)
  258. else:
  259. self.canvas.graph_event_disconnect(self.mr)
  260. self.local_connected = False
  261. self.aligner_old_fill_color = None
  262. self.aligner_old_line_color = None
  263. self.aligned_old_fill_color = None
  264. self.aligned_old_line_color = None
  265. def set_color(self):
  266. new_color = "#15678abf"
  267. new_line_color = new_color
  268. self.target_obj.shapes.redraw(
  269. update_colors=(new_color, new_line_color)
  270. )
  271. def reset_color(self):
  272. self.aligned_obj.shapes.redraw(
  273. update_colors=(self.aligned_old_fill_color, self.aligned_old_line_color)
  274. )
  275. self.aligner_obj.shapes.redraw(
  276. update_colors=(self.aligner_old_fill_color, self.aligner_old_line_color)
  277. )
  278. def reset_fields(self):
  279. self.ui.object_combo.setRootModelIndex(self.app.collection.index(0, 0, QtCore.QModelIndex()))
  280. self.ui.aligner_object_combo.setRootModelIndex(self.app.collection.index(0, 0, QtCore.QModelIndex()))
  281. class AlignUI:
  282. toolName = _("Align Objects")
  283. def __init__(self, layout, app):
  284. self.app = app
  285. self.decimals = self.app.decimals
  286. self.layout = layout
  287. # ## Title
  288. title_label = FCLabel("%s" % self.toolName)
  289. title_label.setStyleSheet("""
  290. QLabel
  291. {
  292. font-size: 16px;
  293. font-weight: bold;
  294. }
  295. """)
  296. self.layout.addWidget(title_label)
  297. self.layout.addWidget(QtWidgets.QLabel(""))
  298. # Form Layout
  299. grid0 = QtWidgets.QGridLayout()
  300. grid0.setColumnStretch(0, 0)
  301. grid0.setColumnStretch(1, 1)
  302. self.layout.addLayout(grid0)
  303. self.aligned_label =FCLabel('<b>%s:</b>' % _("MOVING object"))
  304. grid0.addWidget(self.aligned_label, 0, 0, 1, 2)
  305. self.aligned_label.setToolTip(
  306. _("Specify the type of object to be aligned.\n"
  307. "It can be of type: Gerber or Excellon.\n"
  308. "The selection here decide the type of objects that will be\n"
  309. "in the Object combobox.")
  310. )
  311. # Type of object to be aligned
  312. self.type_obj_radio = RadioSet([
  313. {"label": _("Gerber"), "value": "grb"},
  314. {"label": _("Excellon"), "value": "exc"},
  315. ])
  316. grid0.addWidget(self.type_obj_radio, 3, 0, 1, 2)
  317. # Object to be aligned
  318. self.object_combo = FCComboBox()
  319. self.object_combo.setModel(self.app.collection)
  320. self.object_combo.setRootModelIndex(self.app.collection.index(0, 0, QtCore.QModelIndex()))
  321. self.object_combo.is_last = True
  322. self.object_combo.setToolTip(
  323. _("Object to be aligned.")
  324. )
  325. grid0.addWidget(self.object_combo, 4, 0, 1, 2)
  326. separator_line = QtWidgets.QFrame()
  327. separator_line.setFrameShape(QtWidgets.QFrame.HLine)
  328. separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
  329. grid0.addWidget(separator_line, 5, 0, 1, 2)
  330. grid0.addWidget(FCLabel(''), 6, 0, 1, 2)
  331. self.aligned_label = FCLabel('<b>%s:</b>' % _("DESTINATION object"))
  332. self.aligned_label.setToolTip(
  333. _("Specify the type of object to be aligned to.\n"
  334. "It can be of type: Gerber or Excellon.\n"
  335. "The selection here decide the type of objects that will be\n"
  336. "in the Object combobox.")
  337. )
  338. grid0.addWidget(self.aligned_label, 7, 0, 1, 2)
  339. # Type of object to be aligned to = aligner
  340. self.type_aligner_obj_radio = RadioSet([
  341. {"label": _("Gerber"), "value": "grb"},
  342. {"label": _("Excellon"), "value": "exc"},
  343. ])
  344. grid0.addWidget(self.type_aligner_obj_radio, 8, 0, 1, 2)
  345. # Object to be aligned to = aligner
  346. self.aligner_object_combo = FCComboBox()
  347. self.aligner_object_combo.setModel(self.app.collection)
  348. self.aligner_object_combo.setRootModelIndex(self.app.collection.index(0, 0, QtCore.QModelIndex()))
  349. self.aligner_object_combo.is_last = True
  350. self.aligner_object_combo.setToolTip(
  351. _("Object to be aligned to. Aligner.")
  352. )
  353. grid0.addWidget(self.aligner_object_combo, 9, 0, 1, 2)
  354. separator_line = QtWidgets.QFrame()
  355. separator_line.setFrameShape(QtWidgets.QFrame.HLine)
  356. separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
  357. grid0.addWidget(separator_line, 10, 0, 1, 2)
  358. grid0.addWidget(QtWidgets.QLabel(''), 11, 0, 1, 2)
  359. # Alignment Type
  360. self.a_type_lbl = FCLabel('<b>%s:</b>' % _("Alignment Type"))
  361. self.a_type_lbl.setToolTip(
  362. _("The type of alignment can be:\n"
  363. "- Single Point -> it require a single point of sync, the action will be a translation\n"
  364. "- Dual Point -> it require two points of sync, the action will be translation followed by rotation")
  365. )
  366. self.a_type_radio = RadioSet(
  367. [
  368. {'label': _('Single Point'), 'value': 'sp'},
  369. {'label': _('Dual Point'), 'value': 'dp'}
  370. ])
  371. grid0.addWidget(self.a_type_lbl, 12, 0, 1, 2)
  372. grid0.addWidget(self.a_type_radio, 13, 0, 1, 2)
  373. separator_line = QtWidgets.QFrame()
  374. separator_line.setFrameShape(QtWidgets.QFrame.HLine)
  375. separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
  376. grid0.addWidget(separator_line, 14, 0, 1, 2)
  377. # Buttons
  378. self.align_object_button =FCButton(_("Align Object"))
  379. self.align_object_button.setToolTip(
  380. _("Align the specified object to the aligner object.\n"
  381. "If only one point is used then it assumes translation.\n"
  382. "If tho points are used it assume translation and rotation.")
  383. )
  384. self.align_object_button.setStyleSheet("""
  385. QPushButton
  386. {
  387. font-weight: bold;
  388. }
  389. """)
  390. self.layout.addWidget(self.align_object_button)
  391. self.layout.addStretch()
  392. # ## Reset Tool
  393. self.reset_button = FCButton(_("Reset Tool"))
  394. self.reset_button.setIcon(QtGui.QIcon(self.app.resource_location + '/reset32.png'))
  395. self.reset_button.setToolTip(
  396. _("Will reset the tool parameters.")
  397. )
  398. self.reset_button.setStyleSheet("""
  399. QPushButton
  400. {
  401. font-weight: bold;
  402. }
  403. """)
  404. self.layout.addWidget(self.reset_button)
  405. # #################################### FINSIHED GUI ###########################
  406. # #############################################################################
  407. def confirmation_message(self, accepted, minval, maxval):
  408. if accepted is False:
  409. self.app.inform[str, bool].emit('[WARNING_NOTCL] %s: [%.*f, %.*f]' % (_("Edited value is out of range"),
  410. self.decimals,
  411. minval,
  412. self.decimals,
  413. maxval), False)
  414. else:
  415. self.app.inform[str, bool].emit('[success] %s' % _("Edited value is within limits."), False)
  416. def confirmation_message_int(self, accepted, minval, maxval):
  417. if accepted is False:
  418. self.app.inform[str, bool].emit('[WARNING_NOTCL] %s: [%d, %d]' %
  419. (_("Edited value is out of range"), minval, maxval), False)
  420. else:
  421. self.app.inform[str, bool].emit('[success] %s' % _("Edited value is within limits."), False)