diff --git a/CHANGELOG.md b/CHANGELOG.md
index af379d7a..ab71a6b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ CHANGELOG for FlatCAM beta
=================================================
+11.03.2022
+
+- added a new feature: now in the context menu (and main menu -> Edit) there is a new command that allow to move a selection of objects at specified numeric coordinates (either absolute or relative to current position)
+
10.03.2022
- fixed an issue where using the 'G' shortcut key in Editors will not toggle the grid snap
diff --git a/appGUI/GUIElements.py b/appGUI/GUIElements.py
index b98de4f1..58346d53 100644
--- a/appGUI/GUIElements.py
+++ b/appGUI/GUIElements.py
@@ -2458,6 +2458,47 @@ class FCComboBox2(FCComboBox):
self.setCurrentIndex(0)
+class DialogBoxChoice(QtWidgets.QDialog):
+ def __init__(self, title=None, icon=None, choice='bl'):
+ """
+
+ :param title: string with the window title
+ """
+ super(DialogBoxChoice, self).__init__()
+
+ self.ok = False
+
+ self.setWindowIcon(icon)
+ self.setWindowTitle(str(title))
+
+ grid0 = FCGridLayout(parent=self, h_spacing=5, v_spacing=5)
+
+ self.ref_radio = RadioSet([
+ {"label": _("Bottom Left"), "value": "bl"},
+ {"label": _("Top Left"), "value": "tl"},
+ {"label": _("Bottom Right"), "value": "br"},
+ {"label": _("Top Right"), "value": "tr"},
+ {"label": _("Center"), "value": "c"}
+ ], orientation='vertical', compact=True)
+ self.ref_radio.set_value(choice)
+ grid0.addWidget(self.ref_radio, 0, 0)
+
+ self.button_box = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel,
+ Qt.Orientation.Horizontal, parent=self)
+ grid0.addWidget(self.button_box, 1, 0)
+
+ self.button_box.accepted.connect(self.accept)
+ self.button_box.rejected.connect(self.reject)
+
+ if self.exec() == QtWidgets.QDialog.DialogCode.Accepted:
+ self.ok = True
+ self.location_point = self.ref_radio.get_value()
+ else:
+ self.ok = False
+ self.location_point = None
+
+
class FCInputDialog(QtWidgets.QInputDialog):
def __init__(self, parent=None, ok=False, val=None, title=None, text=None, min=None, max=None, decimals=None,
init_val=None):
diff --git a/appGUI/MainGUI.py b/appGUI/MainGUI.py
index 0e7b2c39..4ada23ad 100644
--- a/appGUI/MainGUI.py
+++ b/appGUI/MainGUI.py
@@ -429,6 +429,9 @@ class MainGUI(QtWidgets.QMainWindow):
# Separator
self.menuedit.addSeparator()
+ self.menuedit_numeric_move = self.menuedit.addAction(
+ QtGui.QIcon(self.app.resource_location + '/move32_bis.png'),
+ '%s\t%s' % (_('Num Move'), ''))
self.menueditorigin = self.menuedit.addAction(
QtGui.QIcon(self.app.resource_location + '/origin16.png'),
'%s\t%s' % (_('Set Origin'), _('O')))
@@ -1664,10 +1667,20 @@ class MainGUI(QtWidgets.QMainWindow):
# ########################################################################
self.popMenu = FCMenu()
- self.popmenu_disable = self.popMenu.addAction(
+ # View
+ self.cmenu_viewmenu = self.popMenu.addMenu(
+ QtGui.QIcon(self.app.resource_location + '/view64.png'), _("View"))
+ self.popmenu_disable = self.cmenu_viewmenu.addAction(
QtGui.QIcon(self.app.resource_location + '/disable32.png'), _("Toggle Visibility"))
- self.popmenu_panel_toggle = self.popMenu.addAction(
+ self.popmenu_panel_toggle = self.cmenu_viewmenu.addAction(
QtGui.QIcon(self.app.resource_location + '/notebook16.png'), _("Toggle Panel"))
+ self.cmenu_viewmenu.addSeparator()
+ self.zoomfit = self.cmenu_viewmenu.addAction(
+ QtGui.QIcon(self.app.resource_location + '/zoom_fit32.png'), _("Zoom Fit"))
+ self.clearplot = self.cmenu_viewmenu.addAction(
+ QtGui.QIcon(self.app.resource_location + '/clear_plot32.png'), _("Clear Plot"))
+ self.replot = self.cmenu_viewmenu.addAction(
+ QtGui.QIcon(self.app.resource_location + '/replot32.png'), _("Replot"))
self.popMenu.addSeparator()
self.cmenu_newmenu = self.popMenu.addMenu(
@@ -1687,16 +1700,6 @@ class MainGUI(QtWidgets.QMainWindow):
self.cmenu_gridmenu = self.popMenu.addMenu(
QtGui.QIcon(self.app.resource_location + '/grid32_menu.png'), _("Grids"))
- # View
- self.cmenu_viewmenu = self.popMenu.addMenu(
- QtGui.QIcon(self.app.resource_location + '/view64.png'), _("View"))
- self.zoomfit = self.cmenu_viewmenu.addAction(
- QtGui.QIcon(self.app.resource_location + '/zoom_fit32.png'), _("Zoom Fit"))
- self.clearplot = self.cmenu_viewmenu.addAction(
- QtGui.QIcon(self.app.resource_location + '/clear_plot32.png'), _("Clear Plot"))
- self.replot = self.cmenu_viewmenu.addAction(
- QtGui.QIcon(self.app.resource_location + '/replot32.png'), _("Replot"))
-
self.popMenu.addSeparator()
# Set colors
@@ -1846,6 +1849,8 @@ class MainGUI(QtWidgets.QMainWindow):
self.popmenu_save.setVisible(False)
self.popMenu.addSeparator()
+ self.popmenu_numeric_move = self.popMenu.addAction(
+ QtGui.QIcon(self.app.resource_location + '/move32_bis.png'), _("Num Move"))
self.popmenu_move2origin = self.popMenu.addAction(
QtGui.QIcon(self.app.resource_location + '/origin2_32.png'), _("Move2Origin"))
self.popmenu_move = self.popMenu.addAction(
diff --git a/appPlugins/ToolMove.py b/appPlugins/ToolMove.py
index a1fe4e03..09e76616 100644
--- a/appPlugins/ToolMove.py
+++ b/appPlugins/ToolMove.py
@@ -151,68 +151,7 @@ class ToolMove(AppTool):
dx = pos[0] - self.point1[0]
dy = pos[1] - self.point1[1]
- # move only the objects selected and plotted and visible
- obj_list = [obj for obj in self.app.collection.get_selected()
- if obj.obj_options['plot'] and obj.visible is True]
-
- def job_move(app_obj):
- with self.app.proc_container.new('%s...' % _("Moving")):
-
- if not obj_list:
- app_obj.app.inform.emit('[ERROR_NOTCL] %s %s' % (_("Failed."),
- _("No object is selected.")))
- return "fail"
-
- try:
- # remove any mark aperture shape that may be displayed
- for sel_obj in obj_list:
- # if the Gerber mark shapes are enabled they need to be disabled before move
- if sel_obj.kind == 'gerber':
- sel_obj.ui.aperture_table_visibility_cb.setChecked(False)
-
- try:
- sel_obj.replotApertures.emit()
- except Exception:
- pass
-
- # offset solid_geometry
- sel_obj.offset((dx, dy))
-
- # Update the object bounding box options
- a, b, c, d = sel_obj.bounds()
- sel_obj.obj_options['xmin'] = a
- sel_obj.obj_options['ymin'] = b
- sel_obj.obj_options['xmax'] = c
- sel_obj.obj_options['ymax'] = d
-
- try:
- sel_obj.set_offset_values()
- except AttributeError:
- # not all objects have this method
- pass
-
- # update the source_file with the new positions
- for sel_obj in obj_list:
- out_name = sel_obj.obj_options["name"]
- if sel_obj.kind == 'gerber':
- sel_obj.source_file = self.app.f_handlers.export_gerber(
- obj_name=out_name, filename=None, local_use=sel_obj, use_thread=False)
- elif sel_obj.kind == 'excellon':
- sel_obj.source_file = self.app.f_handlers.export_excellon(
- obj_name=out_name, filename=None, local_use=sel_obj, use_thread=False)
- except Exception as err:
- app_obj.log.error('[ERROR_NOTCL] %s --> %s' % ('ToolMove.on_left_click()', str(err)))
- return "fail"
-
- # time to plot the moved objects
- app_obj.replot_signal.emit(obj_list)
-
- # delete the selection bounding box
- self.delete_shape()
- self.app.inform.emit('[success] %s %s ...' %
- (str(sel_obj.kind).capitalize(), _('object was moved')))
-
- self.app.worker_task.emit({'fcn': job_move, 'params': [self]})
+ self.move_handler(offset=(dx, dy))
self.clicked_move = 0
self.toggle()
@@ -234,6 +173,85 @@ class ToolMove(AppTool):
self.app.worker_task.emit({'fcn': worker_task, 'params': []})
+ def move_handler(self, offset, objects=None):
+ """
+ Actual move is done here.
+
+ :param offset: How much to move objects on both directions
+ :type offset: tuple
+ :param objects: objects to move
+ :type objects: [list, None]
+ :return:
+ :rtype:
+ """
+
+ dx, dy = offset
+
+ if not objects:
+ # move only the objects selected and plotted and visible
+ obj_list = [
+ obj for obj in self.app.collection.get_selected() if obj.obj_options['plot'] and obj.visible is True
+ ]
+ else:
+ obj_list = objects
+
+ def job_move(app_obj):
+ with self.app.proc_container.new('%s...' % _("Moving")):
+
+ if not obj_list:
+ app_obj.app.inform.emit('[ERROR_NOTCL] %s %s' % (_("Failed."), _("No object is selected.")))
+ return "fail"
+
+ try:
+ # remove any mark aperture shape that may be displayed
+ for sel_obj in obj_list:
+ # if the Gerber mark shapes are enabled they need to be disabled before move
+ if sel_obj.kind == 'gerber':
+ sel_obj.ui.aperture_table_visibility_cb.setChecked(False)
+
+ try:
+ sel_obj.replotApertures.emit()
+ except Exception:
+ pass
+
+ # offset solid_geometry
+ sel_obj.offset((dx, dy))
+
+ # Update the object bounding box options
+ a, b, c, d = sel_obj.bounds()
+ sel_obj.obj_options['xmin'] = a
+ sel_obj.obj_options['ymin'] = b
+ sel_obj.obj_options['xmax'] = c
+ sel_obj.obj_options['ymax'] = d
+
+ try:
+ sel_obj.set_offset_values()
+ except AttributeError:
+ # not all objects have this method
+ pass
+
+ # update the source_file with the new positions
+ for sel_obj in obj_list:
+ out_name = sel_obj.obj_options["name"]
+ if sel_obj.kind == 'gerber':
+ sel_obj.source_file = self.app.f_handlers.export_gerber(
+ obj_name=out_name, filename=None, local_use=sel_obj, use_thread=False)
+ elif sel_obj.kind == 'excellon':
+ sel_obj.source_file = self.app.f_handlers.export_excellon(
+ obj_name=out_name, filename=None, local_use=sel_obj, use_thread=False)
+ except Exception as err:
+ app_obj.log.error('[ERROR_NOTCL] %s --> %s' % ('ToolMove.move_handler()', str(err)))
+ return "fail"
+
+ # time to plot the moved objects
+ app_obj.replot_signal.emit(obj_list)
+
+ # delete the selection bounding box
+ self.delete_shape()
+ self.app.inform.emit('[success] %s' % _("Done."))
+
+ self.app.worker_task.emit({'fcn': job_move, 'params': [self]})
+
def on_move(self, event):
event_pos = event.pos if self.app.use_3d_engine else (event.xdata, event.ydata)
try:
diff --git a/app_Main.py b/app_Main.py
index b1ce5fe5..b2dd382d 100644
--- a/app_Main.py
+++ b/app_Main.py
@@ -20,7 +20,7 @@ from datetime import datetime
import ctypes
import traceback
-from shapely.geometry import Point, MultiPolygon
+from shapely.geometry import Point, MultiPolygon, MultiLineString
from shapely.ops import unary_union
from io import StringIO
@@ -83,7 +83,7 @@ from appGUI.PlotCanvasLegacy import *
from appGUI.PlotCanvas3d import *
from appGUI.MainGUI import *
from appGUI.GUIElements import FCFileSaveDialog, message_dialog, FlatCAMSystemTray, FCInputDialogSlider, \
- FCGridLayout, FCLabel
+ FCGridLayout, FCLabel, DialogBoxChoice
# App Pre-processors
from appPreProcessor import load_preprocessors
@@ -295,7 +295,7 @@ class App(QtCore.QObject):
handler.setFormatter(formatter)
self.log.addHandler(handler)
- self.log.info("FlatCAM Starting...")
+ self.log.info("Starting the application...")
self.qapp = qapp
@@ -1891,13 +1891,13 @@ class App(QtCore.QObject):
self.distance_tool = Distance(self)
self.distance_tool.install(icon=QtGui.QIcon(self.resource_location + '/distance16.png'), pos=self.ui.menuedit,
- before=self.ui.menueditorigin,
+ before=self.ui.menuedit_numeric_move,
separator=False)
self.distance_min_tool = ObjectDistance(self)
self.distance_min_tool.install(icon=QtGui.QIcon(self.resource_location + '/distance_min16.png'),
pos=self.ui.menuedit,
- before=self.ui.menueditorigin,
+ before=self.ui.menuedit_numeric_move,
separator=True)
self.dblsidedtool = DblSidedTool(self)
@@ -1941,7 +1941,7 @@ class App(QtCore.QObject):
self.move_tool = ToolMove(self)
self.move_tool.install(icon=QtGui.QIcon(self.resource_location + '/move16.png'), pos=self.ui.menuedit,
- before=self.ui.menueditorigin, separator=True)
+ before=self.ui.menuedit_numeric_move, separator=True)
self.cutout_tool = CutOut(self)
self.cutout_tool.install(icon=QtGui.QIcon(self.resource_location + '/cut32.png'), pos=self.ui.menu_plugins,
@@ -2193,6 +2193,8 @@ class App(QtCore.QObject):
self.ui.menueditconvert_any2gerber.triggered.connect(lambda: self.convert_any2gerber())
self.ui.menueditconvert_any2excellon.triggered.connect(lambda: self.convert_any2excellon())
+ self.ui.menuedit_numeric_move.triggered.connect(lambda: self.on_numeric_move())
+
self.ui.menueditorigin.triggered.connect(self.on_set_origin)
self.ui.menuedit_move2origin.triggered.connect(self.on_move2origin)
self.ui.menuedit_center_in_origin.triggered.connect(self.on_custom_origin)
@@ -2297,6 +2299,7 @@ class App(QtCore.QObject):
self.ui.popmenu_delete.triggered.connect(self.on_delete)
self.ui.popmenu_edit.triggered.connect(self.object2editor)
self.ui.popmenu_save.triggered.connect(lambda: self.editor2object())
+ self.ui.popmenu_numeric_move.triggered.connect(lambda: self.on_numeric_move())
self.ui.popmenu_move.triggered.connect(self.obj_move)
self.ui.popmenu_move2origin.triggered.connect(self.on_move2origin)
@@ -5798,46 +5801,6 @@ class App(QtCore.QObject):
self.inform.emit('[WARNING_NOTCL] %s' % _("No object is selected."))
return 'fail'
- class DialogBoxChoice(QtWidgets.QDialog):
- def __init__(self, title=None, icon=None, choice='bl'):
- """
-
- :param title: string with the window title
- """
- super(DialogBoxChoice, self).__init__()
-
- self.ok = False
-
- self.setWindowIcon(icon)
- self.setWindowTitle(str(title))
-
- grid0 = FCGridLayout(parent=self, h_spacing=5, v_spacing=5)
-
- self.ref_radio = RadioSet([
- {"label": _("Bottom Left"), "value": "bl"},
- {"label": _("Top Left"), "value": "tl"},
- {"label": _("Bottom Right"), "value": "br"},
- {"label": _("Top Right"), "value": "tr"},
- {"label": _("Center"), "value": "c"}
- ], orientation='vertical', compact=True)
- self.ref_radio.set_value(choice)
- grid0.addWidget(self.ref_radio, 0, 0)
-
- self.button_box = QtWidgets.QDialogButtonBox(
- QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel,
- Qt.Orientation.Horizontal, parent=self)
- grid0.addWidget(self.button_box, 1, 0)
-
- self.button_box.accepted.connect(self.accept)
- self.button_box.rejected.connect(self.reject)
-
- if self.exec() == QtWidgets.QDialog.DialogCode.Accepted:
- self.ok = True
- self.location_point = self.ref_radio.get_value()
- else:
- self.ok = False
- self.location_point = None
-
dia_box = DialogBoxChoice(title=_("Locate ..."),
icon=QtGui.QIcon(self.resource_location + '/locate16.png'),
choice=self.options['global_locate_pt'])
@@ -5939,6 +5902,78 @@ class App(QtCore.QObject):
self.inform.emit('[success] %s' % _("Done."))
return location
+ def on_numeric_move(self, val=None):
+ """
+ Move to a specific location (absolute or relative against current position)'
+
+ :param val: custom offset value, (x, y)
+ :type val: tuple
+ :return: None
+ :rtype: None
+ """
+
+ # move only the objects selected and plotted and visible
+ obj_list = [
+ obj for obj in self.collection.get_selected() if obj.obj_options['plot'] and obj.visible is True
+ ]
+
+ if not obj_list:
+ self.inform.emit('[ERROR_NOTCL] %s %s' % (_("Failed."), _("Nothing selected.")))
+ return
+
+ def bounds_rec(obj):
+ try:
+ minx = Inf
+ miny = Inf
+ maxx = -Inf
+ maxy = -Inf
+
+ work_geo = obj.geoms if isinstance(obj, (MultiPolygon, MultiLineString)) else obj
+ for k in work_geo:
+ minx_, miny_, maxx_, maxy_ = bounds_rec(k)
+ minx = min(minx, minx_)
+ miny = min(miny, miny_)
+ maxx = max(maxx, maxx_)
+ maxy = max(maxy, maxy_)
+ return minx, miny, maxx, maxy
+ except TypeError:
+ # it's a App object, return its bounds
+ if obj:
+ return obj.bounds()
+
+ bounds = bounds_rec(obj_list)
+
+ if not val:
+ dia_box_location = (0.0, 0.0)
+
+ dia_box = DialogBoxRadio(title=_("Move to ..."),
+ label=_("Enter the coordinates in format X,Y:"),
+ icon=QtGui.QIcon(self.resource_location + '/move32_bis.png'),
+ initial_text=dia_box_location,
+ reference=self.options['global_move_ref'])
+
+ if dia_box.ok is True:
+ try:
+ location = eval(dia_box.location)
+
+ if not isinstance(location, tuple):
+ self.inform.emit(_("Wrong coordinates. Enter coordinates in format: X,Y"))
+ return
+
+ if dia_box.reference == 'abs':
+ abs_x = location[0] - bounds[0]
+ abs_y = location[1] - bounds[1]
+ location = (abs_x, abs_y)
+ self.options['global_jump_ref'] = dia_box.reference
+ except Exception:
+ return
+ else:
+ return
+ else:
+ location = val
+
+ self.move_tool.move_handler(offset=location, objects=obj_list)
+
def on_copy_command(self):
"""
Will copy a selection of objects, creating new objects.
@@ -8685,14 +8720,14 @@ class App(QtCore.QObject):
# ## Latest version?
if self.version >= data["version"]:
- self.log.debug("FlatCAM is up to date!")
- self.inform.emit('[success] %s' % _("FlatCAM is up to date!"))
+ self.log.debug("THe application is up to date!")
+ self.inform.emit('[success] %s' % _("The application is up to date!"))
return
self.log.debug("Newer version available.")
title = _("Newer Version Available")
msg = '%s
>%s
%s' % (
- _("There is a newer version of FlatCAM available for download:"),
+ _("There is a newer version available for download:"),
str(data["name"]),
str(data["message"])
)
diff --git a/defaults.py b/defaults.py
index f922a3ea..216dd62a 100644
--- a/defaults.py
+++ b/defaults.py
@@ -40,6 +40,8 @@ class AppDefaults:
"global_jump_ref": 'abs',
"global_locate_pt": 'bl',
+ "global_move_ref": 'abs',
+
"global_toolbar_view": 511,
"global_background_timeout": 300000, # Default value is 5 minutes