# ########################################################## # FlatCAM: 2D Post-processing for Manufacturing # # File Author: Marius Adrian Stanciu (c) # # Date: 09/29/2019 # # MIT Licence # # ########################################################## from PyQt6 import QtWidgets, QtCore, QtGui from appTool import AppTool from appGUI.GUIElements import VerticalScrollArea, FCLabel, FCButton, FCFrame, GLay, FCEntry, FCComboBox2 import logging from copy import deepcopy import math from shapely import Point, MultiPolygon from shapely.ops import nearest_points, unary_union import gettext import appTranslation as fcTranslate import builtins fcTranslate.apply_language('strings') if '_' not in builtins.__dict__: _ = gettext.gettext log = logging.getLogger('base') class ObjectDistance(AppTool): def __init__(self, app): AppTool.__init__(self, app) self.app = app self.canvas = self.app.plotcanvas self.units = self.app.app_units.lower() self.decimals = self.app.decimals self.active = False self.original_call_source = None # ############################################################################# # ######################### Tool GUI ########################################## # ############################################################################# self.ui = ObjectDistanceUI(layout=self.layout, app=self.app) self.pluginName = self.ui.pluginName self.connect_signals_at_init() self.h_point = (0, 0) def run(self, toggle=False): # if the plugin was already launched do not do it again if self.active is True: return if self.app.plugin_tab_locked is True: return # if the splitter is hidden, display it if self.app.ui.splitter.sizes()[0] == 0: self.app.ui.splitter.setSizes([1, 1]) if toggle: pass # if the Tool Tab is hidden display it, else hide it but only if the objectName is the same found_idx = None for idx in range(self.app.ui.notebook.count()): if self.app.ui.notebook.widget(idx).objectName() == "plugin_tab": found_idx = idx break # show the Tab if not found_idx: try: self.app.ui.notebook.addTab(self.app.ui.plugin_tab, _("Plugin")) except RuntimeError: self.app.ui.plugin_tab = QtWidgets.QWidget() self.app.ui.plugin_tab.setObjectName("plugin_tab") self.app.ui.plugin_tab_layout = QtWidgets.QVBoxLayout(self.app.ui.plugin_tab) self.app.ui.plugin_tab_layout.setContentsMargins(2, 2, 2, 2) self.app.ui.plugin_scroll_area = VerticalScrollArea() self.app.ui.plugin_tab_layout.addWidget(self.app.ui.plugin_scroll_area) self.app.ui.notebook.addTab(self.app.ui.plugin_tab, _("Plugin")) # focus on Tool Tab self.app.ui.notebook.setCurrentWidget(self.app.ui.plugin_tab) # Remove anything else in the appGUI self.app.ui.plugin_scroll_area.takeWidget() # Put oneself in the appGUI self.app.ui.plugin_scroll_area.setWidget(self) # Switch notebook to tool page self.app.ui.notebook.setCurrentWidget(self.app.ui.plugin_tab) self.set_tool_ui() self.app.ui.notebook.setTabText(2, _("Object Distance")) # activate the plugin self.activate_measure_tool() def install(self, icon=None, separator=None, **kwargs): AppTool.install(self, icon, separator, shortcut='Shift+M', **kwargs) def connect_signals_at_init(self): self.ui.measure_btn.clicked.connect(self.activate_measure_tool) self.ui.jump_hp_btn.clicked.connect(self.on_jump_to_half_point) self.ui.reset_button.clicked.connect(self.set_tool_ui) self.ui.distance_type_combo.currentIndexChanged.connect(self.on_didstance_type_changed) def set_tool_ui(self): self.units = self.app.app_units.lower() # initial view of the layout self.init_plugin() def init_plugin(self): self.ui.start_entry.set_value('(0, 0)') self.ui.stop_entry.set_value('(0, 0)') self.ui.distance_x_entry.set_value('0.0') self.ui.distance_y_entry.set_value('0.0') self.ui.angle_entry.set_value('0.0') self.ui.total_distance_entry.set_value('0.0') self.ui.half_point_entry.set_value('(0, 0)') self.ui.jump_hp_btn.setDisabled(True) self.active = True def on_didstance_type_changed(self): self.init_plugin() def activate_measure_tool(self): # ENABLE the Measuring TOOL self.ui.jump_hp_btn.setDisabled(False) self.units = self.app.app_units.lower() self.original_call_source = deepcopy(self.app.call_source) measuring_type = self.ui.distance_type_combo.get_value() if measuring_type == 0: # 0 is "nearest points" if self.app.call_source == 'app': first_pos, last_pos = self.measure_nearest_in_app() elif self.app.call_source == 'geo_editor': first_pos, last_pos = self.measure_nearest_in_geo_editor() elif self.app.call_source == 'exc_editor': first_pos, last_pos = self.measure_nearest_in_exc_editor() elif self.app.call_source == 'grb_editor': first_pos, last_pos = self.measure_nearest_in_grb_editor() else: first_pos, last_pos = Point((0, 0)), Point((0, 0)) else: if self.app.call_source == 'app': first_pos, last_pos = self.measure_center_in_app() elif self.app.call_source == 'geo_editor': first_pos, last_pos = self.measure_center_in_geo_editor() elif self.app.call_source == 'exc_editor': first_pos, last_pos = self.measure_center_in_exc_editor() elif self.app.call_source == 'grb_editor': first_pos, last_pos = self.measure_center_in_grb_editor() else: first_pos, last_pos = Point((0, 0)), Point((0, 0)) if first_pos == "fail": return # self.ui.start_entry.set_value("(%.*f, %.*f)" % (self.decimals, first_pos.x, self.decimals, first_pos.y)) # self.ui.stop_entry.set_value("(%.*f, %.*f)" % (self.decimals, last_pos.x, self.decimals, last_pos.y)) # update start point val_start = self.update_start(first_pos) self.display_start(val_start) # update end point val_stop = self.update_end_point(last_pos) self.display_end(val_stop) # update deltas dx, dy = self.update_deltas(first_pt=first_pos, second_pt=last_pos) self.display_deltas(dx, dy) # update angle angle_val = self.update_angle(dx=dx, dy=dy) self.display_angle(angle_val) # update the total distance d = self.update_distance(dx, dy) self.display_distance(d) self.h_point = self.update_half_distance(first_pos, last_pos, dx, dy) if measuring_type == 0: # 0 is "nearest points" if d != 0: self.display_half_distance(self.h_point) else: self.display_half_distance((0.0, 0.0)) intersect_loc = "(%.*f, %.*f)" % (self.decimals, self.h_point[0], self.decimals, self.h_point[1]) msg = '[WARNING_NOTCL] %s: %s' % (_("Objects intersects or touch at"), intersect_loc) self.app.inform.emit(msg) else: self.display_half_distance(self.h_point) self.active = False def measure_nearest_in_app(self): selected_objs = self.app.collection.get_selected() if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" geo_first = selected_objs[0].solid_geometry geo_second = selected_objs[1].solid_geometry if isinstance(selected_objs[0].solid_geometry, list): try: geo_first = MultiPolygon(geo_first) except Exception: geo_first = unary_union(geo_first) if isinstance(selected_objs[1].solid_geometry, list): try: geo_second = MultiPolygon(geo_second) except Exception: geo_second = unary_union(geo_second) first_pos, last_pos = nearest_points(geo_first, geo_second) return first_pos, last_pos def measure_nearest_in_geo_editor(self): selected_objs = self.app.geo_editor.selected if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" first_pos, last_pos = nearest_points(selected_objs[0].geo, selected_objs[1].geo) return first_pos, last_pos def measure_nearest_in_grb_editor(self): selected_objs = self.app.grb_editor.selected if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" first_pos, last_pos = nearest_points(selected_objs[0].geo['solid'], selected_objs[1].geo['solid']) return first_pos, last_pos def measure_nearest_in_exc_editor(self): selected_objs = self.app.exc_editor.selected if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" # the objects are really MultiLinesStrings made out of 2 lines in cross shape xmin, ymin, xmax, ymax = selected_objs[0].geo.bounds first_geo_radius = (xmax - xmin) / 2 first_geo_center = Point(xmin + first_geo_radius, ymin + first_geo_radius) first_geo = first_geo_center.buffer(first_geo_radius) # the objects are really MultiLinesStrings made out of 2 lines in cross shape xmin, ymin, xmax, ymax = selected_objs[1].geo.bounds last_geo_radius = (xmax - xmin) / 2 last_geo_center = Point(xmin + last_geo_radius, ymin + last_geo_radius) last_geo = last_geo_center.buffer(last_geo_radius) first_pos, last_pos = nearest_points(first_geo, last_geo) return first_pos, last_pos def measure_center_in_app(self): selected_objs = self.app.collection.get_selected() if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" geo_first = selected_objs[0].solid_geometry geo_second = selected_objs[1].solid_geometry if isinstance(selected_objs[0].solid_geometry, list): try: geo_first = MultiPolygon(geo_first) except Exception: geo_first = unary_union(geo_first) if isinstance(selected_objs[1].solid_geometry, list): try: geo_second = MultiPolygon(geo_second) except Exception: geo_second = unary_union(geo_second) first_bounds = geo_first.bounds # xmin, ymin, xmax, ymax first_center_x = first_bounds[0] + (first_bounds[2] - first_bounds[0]) / 2 first_center_y = first_bounds[1] + (first_bounds[3] - first_bounds[1]) / 2 second_bounds = geo_second.bounds # xmin, ymin, xmax, ymax second_center_x = second_bounds[0] + (second_bounds[2] - second_bounds[0]) / 2 second_center_y = second_bounds[1] + (second_bounds[3] - second_bounds[1]) / 2 return Point((first_center_x, first_center_y)), Point((second_center_x, second_center_y)) def measure_center_in_geo_editor(self): selected_objs = self.app.geo_editor.selected if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" geo_first = selected_objs[0].geo geo_second = selected_objs[1].geo first_bounds = geo_first.bounds # xmin, ymin, xmax, ymax first_center_x = first_bounds[0] + (first_bounds[2] - first_bounds[0]) / 2 first_center_y = first_bounds[1] + (first_bounds[3] - first_bounds[1]) / 2 second_bounds = geo_second.bounds # xmin, ymin, xmax, ymax second_center_x = second_bounds[0] + (second_bounds[2] - second_bounds[0]) / 2 second_center_y = second_bounds[1] + (second_bounds[3] - second_bounds[1]) / 2 return Point((first_center_x, first_center_y)), Point((second_center_x, second_center_y)) def measure_center_in_grb_editor(self): selected_objs = self.app.grb_editor.selected if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" geo_first = selected_objs[0].geo['solid'] geo_second = selected_objs[1].geo['solid'] first_bounds = geo_first.bounds # xmin, ymin, xmax, ymax first_center_x = first_bounds[0] + (first_bounds[2] - first_bounds[0]) / 2 first_center_y = first_bounds[1] + (first_bounds[3] - first_bounds[1]) / 2 second_bounds = geo_second.bounds # xmin, ymin, xmax, ymax second_center_x = second_bounds[0] + (second_bounds[2] - second_bounds[0]) / 2 second_center_y = second_bounds[1] + (second_bounds[3] - second_bounds[1]) / 2 return Point((first_center_x, first_center_y)), Point((second_center_x, second_center_y)) def measure_center_in_exc_editor(self): selected_objs = self.app.exc_editor.selected if len(selected_objs) != 2: self.app.inform.emit('[WARNING_NOTCL] %s %s' % (_("Select two objects and no more. Currently the selection has objects: "), str(len(selected_objs)))) return "fail", "fail" # the objects are really MultiLinesStrings made out of 2 lines in cross shape xmin, ymin, xmax, ymax = selected_objs[0].geo.bounds first_geo_radius = (xmax - xmin) / 2 first_geo_center = Point(xmin + first_geo_radius, ymin + first_geo_radius) geo_first = first_geo_center.buffer(first_geo_radius) # the objects are really MultiLinesStrings made out of 2 lines in cross shape xmin, ymin, xmax, ymax = selected_objs[1].geo.bounds last_geo_radius = (xmax - xmin) / 2 last_geo_center = Point(xmin + last_geo_radius, ymin + last_geo_radius) geo_second = last_geo_center.buffer(last_geo_radius) first_bounds = geo_first.bounds # xmin, ymin, xmax, ymax first_center_x = first_bounds[0] + (first_bounds[2] - first_bounds[0]) / 2 first_center_y = first_bounds[1] + (first_bounds[3] - first_bounds[1]) / 2 second_bounds = geo_second.bounds # xmin, ymin, xmax, ymax second_center_x = second_bounds[0] + (second_bounds[2] - second_bounds[0]) / 2 second_center_y = second_bounds[1] + (second_bounds[3] - second_bounds[1]) / 2 return Point((first_center_x, first_center_y)), Point((second_center_x, second_center_y)) def on_jump_to_half_point(self): self.app.on_jump_to(custom_location=self.h_point) self.app.inform.emit('[success] %s: %s' % (_("Jumped to the half point between the two selected objects"), "(%.*f, %.*f)" % (self.decimals, self.h_point[0], self.decimals, self.h_point[1]))) def update_angle(self, dx, dy): try: angle = math.degrees(math.atan2(dy, dx)) if angle < 0: angle += 360 except Exception as e: self.app.log.error("ObjectDistance.update_angle() -> %s" % str(e)) return None return angle def display_angle(self, val): if val: self.ui.angle_entry.set_value(str(self.app.dec_format(val, self.decimals))) def update_start(self, pt): return self.app.dec_format(pt.x, self.decimals), self.app.dec_format(pt.y, self.decimals) def display_start(self, val): if val: self.ui.start_entry.set_value(str(val)) def update_end_point(self, pt): # update the end point value return self.app.dec_format(pt.x, self.decimals), self.app.dec_format(pt.y, self.decimals) def display_end(self, val): if val: self.ui.stop_entry.set_value(str(val)) @staticmethod def update_deltas(first_pt, second_pt): dx = first_pt.x - second_pt.x dy = first_pt.y - second_pt.y return dx, dy def display_deltas(self, dx, dy): if dx: self.ui.distance_x_entry.set_value(str(self.app.dec_format(abs(dx), self.decimals))) if dy: self.ui.distance_y_entry.set_value(str(self.app.dec_format(abs(dy), self.decimals))) @staticmethod def update_distance(dx, dy): return math.sqrt(dx ** 2 + dy ** 2) def display_distance(self, val): if val: self.ui.total_distance_entry.set_value('%.*f' % (self.decimals, abs(val))) @staticmethod def update_half_distance(first_pos, last_pos, dx, dy): return min(first_pos.x, last_pos.x) + (abs(dx) / 2), min(first_pos.y, last_pos.y) + (abs(dy) / 2) def display_half_distance(self, val): if val: new_val = ( self.app.dec_format(val[0], self.decimals), self.app.dec_format(val[1], self.decimals) ) self.ui.half_point_entry.set_value(str(new_val)) def on_plugin_cleanup(self): self.active = False self.app.call_source = self.original_call_source self.app.inform.emit('%s' % _("Done.")) class ObjectDistanceUI: pluginName = _("Object Distance") def __init__(self, layout, app): self.app = app self.decimals = self.app.decimals self.layout = layout self.units = self.app.app_units.lower() # ## Title title_label = FCLabel("%s
" % self.pluginName) self.layout.addWidget(title_label) # ############################################################################################################# # Parameters Frame # ############################################################################################################# self.param_label = FCLabel('%s' % _("Parameters"), color='blue', bold=True) self.param_label.setToolTip( _("Parameters used for this tool.") ) self.layout.addWidget(self.param_label) par_frame = FCFrame() self.layout.addWidget(par_frame) param_grid = GLay(v_spacing=5, h_spacing=3) par_frame.setLayout(param_grid) # Distance Type self.distance_type_label = FCLabel("%s:" % _("Type")) self.distance_type_label.setToolTip( _("The type of distance to be calculated.\n" "- Nearest points - minimal distance between objects\n" "- Center points - distance between the center of the bounding boxes") ) self.distance_type_combo = FCComboBox2() self.distance_type_combo.addItems([_("Nearest points"), _("Center points")]) param_grid.addWidget(self.distance_type_label, 0, 0) param_grid.addWidget(self.distance_type_combo, 0, 1) # ############################################################################################################# # Coordinates Frame # ############################################################################################################# self.coords_label = FCLabel('%s' % _("Coordinates"), color='green', bold=True) self.layout.addWidget(self.coords_label) coords_frame = FCFrame() self.layout.addWidget(coords_frame) coords_grid = GLay(v_spacing=5, h_spacing=3) coords_frame.setLayout(coords_grid) # separator_line = QtWidgets.QFrame() # separator_line.setFrameShape(QtWidgets.QFrame.Shape.HLine) # separator_line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) # grid0.addWidget(separator_line, 6, 0, 1, 2) # Start Point self.start_label = FCLabel("%s:" % _('Start point')) self.start_label.setToolTip(_("This is measuring Start point coordinates.")) self.start_entry = FCEntry() self.start_entry.setReadOnly(True) self.start_entry.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.start_entry.setToolTip(_("This is measuring Start point coordinates.")) coords_grid.addWidget(self.start_label, 0, 0) coords_grid.addWidget(self.start_entry, 0, 1) coords_grid.addWidget(FCLabel("%s" % self.units), 0, 2) # End Point self.stop_label = FCLabel("%s:" % _('End point')) self.stop_label.setToolTip(_("This is the measuring Stop point coordinates.")) self.stop_entry = FCEntry() self.stop_entry.setReadOnly(True) self.stop_entry.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.stop_entry.setToolTip(_("This is the measuring Stop point coordinates.")) coords_grid.addWidget(self.stop_label, 2, 0) coords_grid.addWidget(self.stop_entry, 2, 1) coords_grid.addWidget(FCLabel("%s" % self.units), 2, 2) # ############################################################################################################# # Coordinates Frame # ############################################################################################################# self.res_label = FCLabel('%s' % _("Results"), color='red', bold=True) self.layout.addWidget(self.res_label) res_frame = FCFrame() self.layout.addWidget(res_frame) res_grid = GLay(v_spacing=5, h_spacing=3) res_frame.setLayout(res_grid) # DX distance self.distance_x_label = FCLabel('%s:' % _("Dx")) self.distance_x_label.setToolTip(_("This is the distance measured over the X axis.")) self.distance_x_entry = FCEntry() self.distance_x_entry.setReadOnly(True) self.distance_x_entry.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.distance_x_entry.setToolTip(_("This is the distance measured over the X axis.")) res_grid.addWidget(self.distance_x_label, 0, 0) res_grid.addWidget(self.distance_x_entry, 0, 1) res_grid.addWidget(FCLabel("%s" % self.units), 0, 2) # DY distance self.distance_y_label = FCLabel('%s:' % _("Dy")) self.distance_y_label.setToolTip(_("This is the distance measured over the Y axis.")) self.distance_y_entry = FCEntry() self.distance_y_entry.setReadOnly(True) self.distance_y_entry.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.distance_y_entry.setToolTip(_("This is the distance measured over the Y axis.")) res_grid.addWidget(self.distance_y_label, 2, 0) res_grid.addWidget(self.distance_y_entry, 2, 1) res_grid.addWidget(FCLabel("%s" % self.units), 2, 2) # Angle self.angle_label = FCLabel('%s:' % _("Angle")) self.angle_label.setToolTip(_("This is orientation angle of the measuring line.")) self.angle_entry = FCEntry() self.angle_entry.setReadOnly(True) self.angle_entry.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.angle_entry.setToolTip(_("This is orientation angle of the measuring line.")) res_grid.addWidget(self.angle_label, 4, 0) res_grid.addWidget(self.angle_entry, 4, 1) res_grid.addWidget(FCLabel("%s" % "°"), 4, 2) separator_line = QtWidgets.QFrame() separator_line.setFrameShape(QtWidgets.QFrame.Shape.HLine) separator_line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) res_grid.addWidget(separator_line, 6, 0, 1, 3) # Total Distance self.total_distance_label = FCLabel('%s:' % _('DISTANCE'), bold=True) self.total_distance_label.setToolTip(_("This is the point to point Euclidian distance.")) self.total_distance_entry = FCEntry() self.total_distance_entry.setReadOnly(True) self.total_distance_entry.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.total_distance_entry.setToolTip(_("This is the point to point Euclidian distance.")) res_grid.addWidget(self.total_distance_label, 8, 0) res_grid.addWidget(self.total_distance_entry, 8, 1) res_grid.addWidget(FCLabel("%s" % self.units), 8, 2) # Half Point self.half_point_label = FCLabel('%s:' % _('Half Point'), bold=True) self.half_point_label.setToolTip(_("This is the middle point of the point to point Euclidean distance.")) self.half_point_entry = FCEntry() self.half_point_entry.setReadOnly(True) self.half_point_entry.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.half_point_entry.setToolTip(_("This is the middle point of the point to point Euclidean distance.")) res_grid.addWidget(self.half_point_label, 10, 0) res_grid.addWidget(self.half_point_entry, 10, 1) res_grid.addWidget(FCLabel("%s" % self.units), 10, 2) # Buttons self.measure_btn = FCButton(_("Measure")) self.layout.addWidget(self.measure_btn) self.jump_hp_btn = FCButton(_("Jump to Half Point")) self.jump_hp_btn.setDisabled(True) self.layout.addWidget(self.jump_hp_btn) GLay.set_common_column_size([param_grid, coords_grid, res_grid], 0) self.layout.addStretch(1) # ## Reset Tool self.reset_button = FCButton(_("Reset Tool"), bold=True) self.reset_button.setIcon(QtGui.QIcon(self.app.resource_location + '/reset32.png')) self.reset_button.setToolTip( _("Will reset the tool parameters.") ) self.layout.addWidget(self.reset_button) # #################################### FINSIHED GUI ########################### # ############################################################################# def confirmation_message(self, accepted, minval, maxval): if accepted is False: self.app.inform[str, bool].emit('[WARNING_NOTCL] %s: [%.*f, %.*f]' % (_("Edited value is out of range"), self.decimals, minval, self.decimals, maxval), False) else: self.app.inform[str, bool].emit('[success] %s' % _("Edited value is within limits."), False) def confirmation_message_int(self, accepted, minval, maxval): if accepted is False: self.app.inform[str, bool].emit('[WARNING_NOTCL] %s: [%d, %d]' % (_("Edited value is out of range"), minval, maxval), False) else: self.app.inform[str, bool].emit('[success] %s' % _("Edited value is within limits."), False)