diff --git a/FlatCAMApp.py b/FlatCAMApp.py index 26669d16..1f9f81b6 100644 --- a/FlatCAMApp.py +++ b/FlatCAMApp.py @@ -708,7 +708,9 @@ class App(QtCore.QObject): "tools_transform_offset_x": 0.0, "tools_transform_offset_y": 0.0, "tools_transform_mirror_reference": False, - "tools_transform_mirror_point": (0, 0) + "tools_transform_mirror_point": (0, 0), + + "tools_solderpaste_tools": "1.0, 0.3", }) ############################### diff --git a/README.md b/README.md index 229a3c7f..81df3261 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ CAD program, and create G-Code for Isolation routing. ================================================= +20.02.2019 + +- finished added a Tool Table for Tool SolderPaste +- working on multi tool soder paste dispensing + 19.02.2019 - added the ability to compress the FlatCAM project on save with LZMA compression. There is a setting in Edit -> Preferences -> Compression Level between 0 and 9. 9 level yields best compression at the price of RAM usage and time spent. diff --git a/flatcamTools/ToolSolderPaste.py b/flatcamTools/ToolSolderPaste.py index 85684584..f78b7a05 100644 --- a/flatcamTools/ToolSolderPaste.py +++ b/flatcamTools/ToolSolderPaste.py @@ -27,51 +27,121 @@ class ToolSolderPaste(FlatCAMTool): self.layout.addWidget(title_label) ## Form Layout - form_layout = QtWidgets.QFormLayout() - self.layout.addLayout(form_layout) + obj_form_layout = QtWidgets.QFormLayout() + self.layout.addLayout(obj_form_layout) - ## Type of object to be cutout - self.type_obj_combo = QtWidgets.QComboBox() - self.type_obj_combo.addItem("Gerber") - self.type_obj_combo.addItem("Excellon") - self.type_obj_combo.addItem("Geometry") - - # we get rid of item1 ("Excellon") as it is not suitable for creating solderpaste - self.type_obj_combo.view().setRowHidden(1, True) - self.type_obj_combo.setItemIcon(0, QtGui.QIcon("share/flatcam_icon16.png")) - self.type_obj_combo.setItemIcon(2, QtGui.QIcon("share/geometry16.png")) - - self.type_obj_combo_label = QtWidgets.QLabel("Object Type:") - self.type_obj_combo_label.setToolTip( - "Specify the type of object to be used for solder paste dispense.\n" - "It can be of type: Gerber or Geometry.\n" - "What is selected here will dictate the kind\n" - "of objects that will populate the 'Object' combobox." - ) - form_layout.addRow(self.type_obj_combo_label, self.type_obj_combo) - - ## Object to be used for solderpaste dispensing + ## Gerber Object to be used for solderpaste dispensing self.obj_combo = QtWidgets.QComboBox() self.obj_combo.setModel(self.app.collection) self.obj_combo.setRootModelIndex(self.app.collection.index(0, 0, QtCore.QModelIndex())) self.obj_combo.setCurrentIndex(1) - self.object_label = QtWidgets.QLabel("Object:") + self.object_label = QtWidgets.QLabel("Gerber: ") self.object_label.setToolTip( - "Solder paste object. " + "Gerber Solder paste object. " ) - form_layout.addRow(self.object_label, self.obj_combo) + obj_form_layout.addRow(self.object_label, self.obj_combo) - # Offset distance - self.nozzle_dia_entry = FloatEntry() - self.nozzle_dia_entry.setValidator(QtGui.QDoubleValidator(0.0000, 9.9999, 4)) - self.nozzle_dia_label = QtWidgets.QLabel("Nozzle Diameter:") - self.nozzle_dia_label.setToolTip( - "The offset for the solder paste.\n" - "Due of the diameter of the solder paste dispenser\n" - "we need to adjust the quantity of solder paste." + #### Tools #### + self.tools_table_label = QtWidgets.QLabel('Tools Table') + self.tools_table_label.setToolTip( + "Tools pool from which the algorithm\n" + "will pick the ones used for dispensing solder paste." ) - form_layout.addRow(self.nozzle_dia_label, self.nozzle_dia_entry) + self.layout.addWidget(self.tools_table_label) + + self.tools_table = FCTable() + self.layout.addWidget(self.tools_table) + + self.tools_table.setColumnCount(3) + self.tools_table.setHorizontalHeaderLabels(['#', 'Diameter', '']) + self.tools_table.setColumnHidden(2, True) + self.tools_table.setSortingEnabled(False) + # self.tools_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + + self.tools_table.horizontalHeaderItem(0).setToolTip( + "This is the Tool Number.\n" + "The solder dispensing will start with the tool with the biggest \n" + "diameter, continuing until there are no more Nozzle tools.\n" + "If there are no longer tools but there are still pads not covered\n " + "with solder paste, the app will issue a warning message box." + ) + self.tools_table.horizontalHeaderItem(1).setToolTip( + "Nozzle tool Diameter. It's value (in current FlatCAM units)\n" + "is the width of the solder paste dispensed.") + + self.empty_label = QtWidgets.QLabel('') + self.layout.addWidget(self.empty_label) + + #### Add a new Tool #### + hlay_tools = QtWidgets.QHBoxLayout() + self.layout.addLayout(hlay_tools) + + self.addtool_entry_lbl = QtWidgets.QLabel('Nozzle Dia:') + self.addtool_entry_lbl.setToolTip( + "Diameter for the new Nozzle tool to add in the Tool Table" + ) + self.addtool_entry = FCEntry() + + # hlay.addWidget(self.addtool_label) + # hlay.addStretch() + hlay_tools.addWidget(self.addtool_entry_lbl) + hlay_tools.addWidget(self.addtool_entry) + + grid0 = QtWidgets.QGridLayout() + self.layout.addLayout(grid0) + + self.addtool_btn = QtWidgets.QPushButton('Add') + self.addtool_btn.setToolTip( + "Add a new nozzle tool to the Tool Table\n" + "with the diameter specified above." + ) + + self.deltool_btn = QtWidgets.QPushButton('Delete') + self.deltool_btn.setToolTip( + "Delete a selection of tools in the Tool Table\n" + "by first selecting a row(s) in the Tool Table." + ) + + self.soldergeo_btn = QtWidgets.QPushButton("Generate Geo") + self.soldergeo_btn.setToolTip( + "Generate solder paste dispensing geometry." + ) + + grid0.addWidget(self.addtool_btn, 0, 0) + # grid2.addWidget(self.copytool_btn, 0, 1) + grid0.addWidget(self.deltool_btn, 0, 2) + grid0.addWidget(self.soldergeo_btn, 2, 2) + + ## Form Layout + geo_form_layout = QtWidgets.QFormLayout() + self.layout.addLayout(geo_form_layout) + + ## Gerber Object to be used for solderpaste dispensing + self.geo_obj_combo = QtWidgets.QComboBox() + self.geo_obj_combo.setModel(self.app.collection) + self.geo_obj_combo.setRootModelIndex(self.app.collection.index(2, 0, QtCore.QModelIndex())) + self.geo_obj_combo.setCurrentIndex(1) + + self.geo_object_label = QtWidgets.QLabel("Geometry:") + self.geo_object_label.setToolTip( + "Geometry Solder paste object.\n" + "In order to enable the GCode generation section,\n" + "the name of the object has to end in:\n" + "'_solderpaste' as a protection." + ) + geo_form_layout.addRow(self.geo_object_label, self.geo_obj_combo) + + self.gcode_frame = QtWidgets.QFrame() + self.gcode_frame.setContentsMargins(0, 0, 0, 0) + self.layout.addWidget(self.gcode_frame) + self.gcode_box = QtWidgets.QVBoxLayout() + self.gcode_box.setContentsMargins(0, 0, 0, 0) + self.gcode_frame.setLayout(self.gcode_box) + + ## Form Layout + form_layout = QtWidgets.QFormLayout() + self.gcode_box.addLayout(form_layout) # Z dispense start self.z_start_entry = FCEntry() @@ -197,15 +267,9 @@ class ToolSolderPaste(FlatCAMTool): ## Buttons hlay = QtWidgets.QHBoxLayout() - self.layout.addLayout(hlay) + self.gcode_box.addLayout(hlay) hlay.addStretch() - self.soldergeo_btn = QtWidgets.QPushButton("Generate Geo") - self.soldergeo_btn.setToolTip( - "Generate solder paste dispensing geometry." - ) - hlay.addWidget(self.soldergeo_btn) - self.solder_gcode = QtWidgets.QPushButton("Generate GCode") self.solder_gcode.setToolTip( @@ -214,19 +278,19 @@ class ToolSolderPaste(FlatCAMTool): ) hlay.addWidget(self.solder_gcode) - self.layout.addStretch() + self.gcode_frame.setDisabled(True) + + self.tools = {} + self.tooluid = 0 + ## Signals + self.addtool_btn.clicked.connect(self.on_tool_add) + self.deltool_btn.clicked.connect(self.on_tool_delete) self.soldergeo_btn.clicked.connect(self.on_create_geo) self.solder_gcode.clicked.connect(self.on_create_gcode) - - self.type_obj_combo.currentIndexChanged.connect(self.on_type_obj_index_changed) - - def on_type_obj_index_changed(self, index): - obj_type = self.type_obj_combo.currentIndex() - self.obj_combo.setRootModelIndex(self.app.collection.index(obj_type, 0, QtCore.QModelIndex())) - self.obj_combo.setCurrentIndex(0) + self.geo_obj_combo.currentIndexChanged.connect(self.on_geo_select) def run(self): self.app.report_usage("ToolSolderPaste()") @@ -238,14 +302,290 @@ class ToolSolderPaste(FlatCAMTool): if self.app.ui.splitter.sizes()[0] == 0: self.app.ui.splitter.setSizes([1, 1]) + self.build_ui() self.app.ui.notebook.setTabText(2, "SolderPaste Tool") def install(self, icon=None, separator=None, **kwargs): FlatCAMTool.install(self, icon, separator, shortcut='ALT+K', **kwargs) def set_tool_ui(self): + + # self.ncc_overlap_entry.set_value(self.app.defaults["tools_nccoverlap"]) + # self.ncc_margin_entry.set_value(self.app.defaults["tools_nccmargin"]) + # self.ncc_method_radio.set_value(self.app.defaults["tools_nccmethod"]) + # self.ncc_connect_cb.set_value(self.app.defaults["tools_nccconnect"]) + # self.ncc_contour_cb.set_value(self.app.defaults["tools_ncccontour"]) + # self.ncc_rest_cb.set_value(self.app.defaults["tools_nccrest"]) + + self.tools_table.setupContextMenu() + self.tools_table.addContextMenu( + "Add", lambda: self.on_tool_add(dia=None, muted=None), icon=QtGui.QIcon("share/plus16.png")) + self.tools_table.addContextMenu( + "Delete", lambda: + self.on_tool_delete(rows_to_delete=None, all=None), icon=QtGui.QIcon("share/delete32.png")) + + try: + dias = [float(eval(dia)) for dia in self.app.defaults["tools_solderpaste_tools"].split(",")] + except: + log.error("At least one Nozzle tool diameter needed. " + "Verify in Edit -> Preferences -> TOOLS -> Solder Paste Tools.") + return + + self.tooluid = 0 + + self.tools.clear() + for tool_dia in dias: + self.tooluid += 1 + self.tools.update({ + int(self.tooluid): { + 'tooldia': float('%.4f' % tool_dia), + 'solid_geometry': [] + } + }) + + self.name = "" + self.obj = None + + self.units = self.app.general_options_form.general_app_group.units_radio.get_value().upper() self.reset_fields() - pass + + def build_ui(self): + self.ui_disconnect() + + # updated units + self.units = self.app.general_options_form.general_app_group.units_radio.get_value().upper() + + if self.units == "IN": + self.addtool_entry.set_value(0.039) + else: + self.addtool_entry.set_value(1) + + sorted_tools = [] + for k, v in self.tools.items(): + sorted_tools.append(float('%.4f' % float(v['tooldia']))) + sorted_tools.sort(reverse=True) + + n = len(sorted_tools) + self.tools_table.setRowCount(n) + tool_id = 0 + + for tool_sorted in sorted_tools: + for tooluid_key, tooluid_value in self.tools.items(): + if float('%.4f' % tooluid_value['tooldia']) == tool_sorted: + tool_id += 1 + id = QtWidgets.QTableWidgetItem('%d' % int(tool_id)) + id.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + row_no = tool_id - 1 + self.tools_table.setItem(row_no, 0, id) # Tool name/id + + # Make sure that the drill diameter when in MM is with no more than 2 decimals + # There are no drill bits in MM with more than 3 decimals diameter + # For INCH the decimals should be no more than 3. There are no drills under 10mils + if self.units == 'MM': + dia = QtWidgets.QTableWidgetItem('%.2f' % tooluid_value['tooldia']) + else: + dia = QtWidgets.QTableWidgetItem('%.3f' % tooluid_value['tooldia']) + + dia.setFlags(QtCore.Qt.ItemIsEnabled) + + tool_uid_item = QtWidgets.QTableWidgetItem(str(int(tooluid_key))) + + self.tools_table.setItem(row_no, 1, dia) # Diameter + + self.tools_table.setItem(row_no, 2, tool_uid_item) # Tool unique ID + + # make the diameter column editable + for row in range(tool_id): + self.tools_table.item(row, 1).setFlags( + QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + + # all the tools are selected by default + self.tools_table.selectColumn(0) + # + self.tools_table.resizeColumnsToContents() + self.tools_table.resizeRowsToContents() + + vertical_header = self.tools_table.verticalHeader() + vertical_header.hide() + self.tools_table.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + horizontal_header = self.tools_table.horizontalHeader() + horizontal_header.setMinimumSectionSize(10) + horizontal_header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) + horizontal_header.resizeSection(0, 20) + horizontal_header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) + + # self.tools_table.setSortingEnabled(True) + # sort by tool diameter + # self.tools_table.sortItems(1) + + self.tools_table.setMinimumHeight(self.tools_table.getHeight()) + self.tools_table.setMaximumHeight(self.tools_table.getHeight()) + + self.ui_connect() + + def ui_connect(self): + self.tools_table.itemChanged.connect(self.on_tool_edit) + + def ui_disconnect(self): + try: + # if connected, disconnect the signal from the slot on item_changed as it creates issues + self.tools_table.itemChanged.disconnect(self.on_tool_edit) + except: + pass + + def on_tool_add(self, dia=None, muted=None): + + self.ui_disconnect() + + if dia: + tool_dia = dia + else: + try: + tool_dia = float(self.addtool_entry.get_value()) + except ValueError: + # try to convert comma to decimal point. if it's still not working error message and return + try: + tool_dia = float(self.addtool_entry.get_value().replace(',', '.')) + except ValueError: + self.app.inform.emit("[ERROR_NOTCL]Wrong value format entered, " + "use a number.") + return + if tool_dia is None: + self.build_ui() + self.app.inform.emit("[WARNING_NOTCL] Please enter a tool diameter to add, in Float format.") + return + + if tool_dia == 0: + self.app.inform.emit("[WARNING_NOTCL] Please enter a tool diameter with non-zero value, in Float format.") + return + + # construct a list of all 'tooluid' in the self.tools + tool_uid_list = [] + for tooluid_key in self.tools: + tool_uid_item = int(tooluid_key) + tool_uid_list.append(tool_uid_item) + + # find maximum from the temp_uid, add 1 and this is the new 'tooluid' + if not tool_uid_list: + max_uid = 0 + else: + max_uid = max(tool_uid_list) + self.tooluid = int(max_uid + 1) + + tool_dias = [] + for k, v in self.tools.items(): + for tool_v in v.keys(): + if tool_v == 'tooldia': + tool_dias.append(float('%.4f' % v[tool_v])) + + if float('%.4f' % tool_dia) in tool_dias: + if muted is None: + self.app.inform.emit("[WARNING_NOTCL]Adding Nozzle tool cancelled. Tool already in Tool Table.") + self.tools_table.itemChanged.connect(self.on_tool_edit) + return + else: + if muted is None: + self.app.inform.emit("[success] New Nozzle tool added to Tool Table.") + self.tools.update({ + int(self.tooluid): { + 'tooldia': float('%.4f' % tool_dia), + 'solid_geometry': [] + } + }) + + self.build_ui() + + def on_tool_edit(self): + self.ui_disconnect() + + tool_dias = [] + for k, v in self.tools.items(): + for tool_v in v.keys(): + if tool_v == 'tooldia': + tool_dias.append(float('%.4f' % v[tool_v])) + + for row in range(self.tools_table.rowCount()): + + try: + new_tool_dia = float(self.tools_table.item(row, 1).text()) + except ValueError: + # try to convert comma to decimal point. if it's still not working error message and return + try: + new_tool_dia = float(self.tools_table.item(row, 1).text().replace(',', '.')) + except ValueError: + self.app.inform.emit("[ERROR_NOTCL]Wrong value format entered, " + "use a number.") + return + + tooluid = int(self.tools_table.item(row, 2).text()) + + # identify the tool that was edited and get it's tooluid + if new_tool_dia not in tool_dias: + self.tools[tooluid]['tooldia'] = new_tool_dia + self.app.inform.emit("[success] Nozzle tool from Tool Table was edited.") + self.build_ui() + return + else: + # identify the old tool_dia and restore the text in tool table + for k, v in self.tools.items(): + if k == tooluid: + old_tool_dia = v['tooldia'] + break + restore_dia_item = self.tools_table.item(row, 1) + restore_dia_item.setText(str(old_tool_dia)) + self.app.inform.emit("[WARNING_NOTCL] Edit cancelled. New diameter value is already in the Tool Table.") + self.build_ui() + + def on_tool_delete(self, rows_to_delete=None, all=None): + self.ui_disconnect() + + deleted_tools_list = [] + + if all: + self.tools.clear() + self.build_ui() + return + + if rows_to_delete: + try: + for row in rows_to_delete: + tooluid_del = int(self.tools_table.item(row, 2).text()) + deleted_tools_list.append(tooluid_del) + except TypeError: + deleted_tools_list.append(rows_to_delete) + + for t in deleted_tools_list: + self.tools.pop(t, None) + self.build_ui() + return + + try: + if self.tools_table.selectedItems(): + for row_sel in self.tools_table.selectedItems(): + row = row_sel.row() + if row < 0: + continue + tooluid_del = int(self.tools_table.item(row, 2).text()) + deleted_tools_list.append(tooluid_del) + + for t in deleted_tools_list: + self.tools.pop(t, None) + + except AttributeError: + self.app.inform.emit("[WARNING_NOTCL]Delete failed. Select a Nozzle tool to delete.") + return + except Exception as e: + log.debug(str(e)) + + self.app.inform.emit("[success] Nozzle tool(s) deleted from Tool Table.") + self.build_ui() + + def on_geo_select(self): + if self.geo_obj_combo.currentText().rpartition('_')[2] == 'solderpaste': + self.gcode_frame.setDisabled(False) + else: + self.gcode_frame.setDisabled(True) @staticmethod def distance(pt1, pt2): @@ -260,24 +600,21 @@ class ToolSolderPaste(FlatCAMTool): if type(obj.solid_geometry) is not list: obj.solid_geometry = [obj.solid_geometry] - try: - offset = self.nozzle_dia_entry.get_value() / 2 - except Exception as e: - log.debug("ToolSoderPaste.on_create_geo() --> %s" % str(e)) - self.app.inform.emit("[ERROR_NOTCL] Failed. Offset value is missing ...") - return - - if offset is None: - self.app.inform.emit("[ERROR_NOTCL] Failed. Offset value is missing ...") - return + # Sort tools in descending order + sorted_tools = [] + for k, v in self.tools.items(): + sorted_tools.append(float('%.4f' % float(v['tooldia']))) + sorted_tools.sort(reverse=True) def geo_init(geo_obj, app_obj): geo_obj.solid_geometry = [] - geo_obj.multigeo = False - geo_obj.multitool = False + geo_obj.tools = {} + geo_obj.multigeo = True + geo_obj.multitool = True geo_obj.tools = {} def solder_line(p, offset): + xmin, ymin, xmax, ymax = p.bounds min = [xmin, ymin] @@ -313,26 +650,58 @@ class ToolSolderPaste(FlatCAMTool): geo = geo.intersection(offseted_poly) return geo - for g in obj.solid_geometry: - if type(g) == MultiPolygon: - for poly in g: - geom = solder_line(poly, offset=offset) - if geom == 'fail': - app_obj.inform.emit("[ERROR_NOTCL] The Nozzle diameter is too big for certain features.") - return 'fail' - if not geom.is_empty: - geo_obj.solid_geometry.append(geom) - elif type(g) == Polygon: - geom = solder_line(g, offset=offset) - if geom == 'fail': - app_obj.inform.emit("[ERROR_NOTCL] The Nozzle diameter is too big for certain features.") - return 'fail' - if not geom.is_empty: - geo_obj.solid_geometry.append(geom) + work_geo = obj.solid_geometry + rest_geo = [] + tooluid = 1 + + for tool in sorted_tools: + offset = tool / 2 + + for uid, v in self.tools.items(): + if float('%.4f' % float(v['tooldia'])) == tool: + tooluid = int(uid) + break + + for g in work_geo: + if type(g) == MultiPolygon: + for poly in g: + geom = solder_line(poly, offset=offset) + if geom != 'fail': + try: + geo_obj.tools[tooluid]['solid_geometry'].append(geom) + except KeyError: + geo_obj.tools[tooluid] = {} + geo_obj.tools[tooluid]['solid_geometry'] = [] + geo_obj.tools[tooluid]['solid_geometry'].append(geom) + else: + rest_geo.append(poly) + elif type(g) == Polygon: + geom = solder_line(g, offset=offset) + if geom != 'fail': + try: + geo_obj.tools[tooluid]['solid_geometry'].append(geom) + except KeyError: + geo_obj.tools[tooluid] = {} + geo_obj.tools[tooluid]['solid_geometry'] = [] + geo_obj.tools[tooluid]['solid_geometry'].append(geom) + else: + rest_geo.append(g) + + work_geo = rest_geo + if not work_geo: + app_obj.inform.emit("[success] Solder Paste geometry generated successfully...") + return + + # if we still have geometry not processed at the end of the tools then we failed + # some or all the pads are not covered with solder paste + if rest_geo: + app_obj.inform.emit("[WARNING_NOTCL] Some or all pads have no solder " + "due of inadequate nozzle diameters...") + return 'fail' def job_thread(app_obj): try: - app_obj.new_object("geometry", name + "_temp_solderpaste", geo_init) + app_obj.new_object("geometry", name + "_solderpaste", geo_init) except Exception as e: proc.done() traceback.print_stack()