- updated the Etch Compensation Tool such that the resulting Gerber object will have the apertures attributes ('size', 'width', 'height') updated to the changes
450 lines
17 KiB
Python
450 lines
17 KiB
Python
# ##########################################################
|
|
# FlatCAM: 2D Post-processing for Manufacturing #
|
|
# File Author: Marius Adrian Stanciu (c) #
|
|
# Date: 2/14/2020 #
|
|
# MIT Licence #
|
|
# ##########################################################
|
|
|
|
from PyQt5 import QtWidgets, QtCore
|
|
|
|
from AppTool import AppTool
|
|
from AppGUI.GUIElements import FCButton, FCDoubleSpinner, RadioSet, FCComboBox, NumericalEvalEntry, FCEntry
|
|
|
|
from shapely.ops import unary_union
|
|
|
|
from copy import deepcopy
|
|
import math
|
|
|
|
import logging
|
|
import gettext
|
|
import AppTranslation as fcTranslate
|
|
import builtins
|
|
|
|
fcTranslate.apply_language('strings')
|
|
if '_' not in builtins.__dict__:
|
|
_ = gettext.gettext
|
|
|
|
log = logging.getLogger('base')
|
|
|
|
|
|
class ToolEtchCompensation(AppTool):
|
|
|
|
toolName = _("Etch Compensation Tool")
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
self.decimals = self.app.decimals
|
|
|
|
AppTool.__init__(self, app)
|
|
|
|
self.tools_frame = QtWidgets.QFrame()
|
|
self.tools_frame.setContentsMargins(0, 0, 0, 0)
|
|
self.layout.addWidget(self.tools_frame)
|
|
self.tools_box = QtWidgets.QVBoxLayout()
|
|
self.tools_box.setContentsMargins(0, 0, 0, 0)
|
|
self.tools_frame.setLayout(self.tools_box)
|
|
|
|
# Title
|
|
title_label = QtWidgets.QLabel("%s" % self.toolName)
|
|
title_label.setStyleSheet("""
|
|
QLabel
|
|
{
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
}
|
|
""")
|
|
self.tools_box.addWidget(title_label)
|
|
|
|
# Grid Layout
|
|
grid0 = QtWidgets.QGridLayout()
|
|
grid0.setColumnStretch(0, 0)
|
|
grid0.setColumnStretch(1, 1)
|
|
self.tools_box.addLayout(grid0)
|
|
|
|
grid0.addWidget(QtWidgets.QLabel(''), 0, 0, 1, 2)
|
|
|
|
# Target Gerber Object
|
|
self.gerber_combo = FCComboBox()
|
|
self.gerber_combo.setModel(self.app.collection)
|
|
self.gerber_combo.setRootModelIndex(self.app.collection.index(0, 0, QtCore.QModelIndex()))
|
|
self.gerber_combo.is_last = True
|
|
self.gerber_combo.obj_type = "Gerber"
|
|
|
|
self.gerber_label = QtWidgets.QLabel('<b>%s:</b>' % _("GERBER"))
|
|
self.gerber_label.setToolTip(
|
|
_("Gerber object that will be inverted.")
|
|
)
|
|
|
|
grid0.addWidget(self.gerber_label, 1, 0, 1, 2)
|
|
grid0.addWidget(self.gerber_combo, 2, 0, 1, 2)
|
|
|
|
grid0.addWidget(QtWidgets.QLabel(""), 3, 0, 1, 2)
|
|
|
|
self.util_label = QtWidgets.QLabel("<b>%s:</b>" % _("Utilities"))
|
|
self.util_label.setToolTip('%s.' % _("Conversion utilities"))
|
|
|
|
grid0.addWidget(self.util_label, 4, 0, 1, 2)
|
|
|
|
# Oz to um conversion
|
|
self.oz_um_label = QtWidgets.QLabel('%s:' % _('Oz to Microns'))
|
|
self.oz_um_label.setToolTip(
|
|
_("Will convert from oz thickness to microns [um].\n"
|
|
"Can use formulas with operators: /, *, +, -, %, .\n"
|
|
"The real numbers use the dot decimals separator.")
|
|
)
|
|
grid0.addWidget(self.oz_um_label, 5, 0, 1, 2)
|
|
|
|
hlay_1 = QtWidgets.QHBoxLayout()
|
|
|
|
self.oz_entry = NumericalEvalEntry(border_color='#0069A9')
|
|
self.oz_entry.setPlaceholderText(_("Oz value"))
|
|
self.oz_to_um_entry = FCEntry()
|
|
self.oz_to_um_entry.setPlaceholderText(_("Microns value"))
|
|
self.oz_to_um_entry.setReadOnly(True)
|
|
|
|
hlay_1.addWidget(self.oz_entry)
|
|
hlay_1.addWidget(self.oz_to_um_entry)
|
|
grid0.addLayout(hlay_1, 6, 0, 1, 2)
|
|
|
|
# Mils to um conversion
|
|
self.mils_um_label = QtWidgets.QLabel('%s:' % _('Mils to Microns'))
|
|
self.mils_um_label.setToolTip(
|
|
_("Will convert from mils to microns [um].\n"
|
|
"Can use formulas with operators: /, *, +, -, %, .\n"
|
|
"The real numbers use the dot decimals separator.")
|
|
)
|
|
grid0.addWidget(self.mils_um_label, 7, 0, 1, 2)
|
|
|
|
hlay_2 = QtWidgets.QHBoxLayout()
|
|
|
|
self.mils_entry = NumericalEvalEntry(border_color='#0069A9')
|
|
self.mils_entry.setPlaceholderText(_("Mils value"))
|
|
self.mils_to_um_entry = FCEntry()
|
|
self.mils_to_um_entry.setPlaceholderText(_("Microns value"))
|
|
self.mils_to_um_entry.setReadOnly(True)
|
|
|
|
hlay_2.addWidget(self.mils_entry)
|
|
hlay_2.addWidget(self.mils_to_um_entry)
|
|
grid0.addLayout(hlay_2, 8, 0, 1, 2)
|
|
|
|
grid0.addWidget(QtWidgets.QLabel(""), 9, 0, 1, 2)
|
|
|
|
self.param_label = QtWidgets.QLabel("<b>%s:</b>" % _("Parameters"))
|
|
self.param_label.setToolTip('%s.' % _("Parameters for this tool"))
|
|
|
|
grid0.addWidget(self.param_label, 10, 0, 1, 2)
|
|
|
|
# Thickness
|
|
self.thick_label = QtWidgets.QLabel('%s:' % _('Copper Thickness'))
|
|
self.thick_label.setToolTip(
|
|
_("The thickness of the copper foil.\n"
|
|
"In microns [um].")
|
|
)
|
|
self.thick_entry = FCDoubleSpinner(callback=self.confirmation_message)
|
|
self.thick_entry.set_precision(self.decimals)
|
|
self.thick_entry.set_range(0.0000, 9999.9999)
|
|
self.thick_entry.setObjectName(_("Thickness"))
|
|
|
|
grid0.addWidget(self.thick_label, 12, 0)
|
|
grid0.addWidget(self.thick_entry, 12, 1)
|
|
|
|
self.ratio_label = QtWidgets.QLabel('%s:' % _("Ratio"))
|
|
self.ratio_label.setToolTip(
|
|
_("The ratio of lateral etch versus depth etch.\n"
|
|
"Can be:\n"
|
|
"- custom -> the user will enter a custom value\n"
|
|
"- preselection -> value which depends on a selection of etchants")
|
|
)
|
|
self.ratio_radio = RadioSet([
|
|
{'label': _('Etch Factor'), 'value': 'factor'},
|
|
{'label': _('Etchants list'), 'value': 'etch_list'},
|
|
{'label': _('Manual offset'), 'value': 'manual'}
|
|
], orientation='vertical', stretch=False)
|
|
|
|
grid0.addWidget(self.ratio_label, 14, 0, 1, 2)
|
|
grid0.addWidget(self.ratio_radio, 16, 0, 1, 2)
|
|
|
|
# Etchants
|
|
self.etchants_label = QtWidgets.QLabel('%s:' % _('Etchants'))
|
|
self.etchants_label.setToolTip(
|
|
_("A list of etchants.")
|
|
)
|
|
self.etchants_combo = FCComboBox(callback=self.confirmation_message)
|
|
self.etchants_combo.setObjectName(_("Etchants"))
|
|
self.etchants_combo.addItems(["CuCl2", "Fe3Cl"])
|
|
|
|
grid0.addWidget(self.etchants_label, 18, 0)
|
|
grid0.addWidget(self.etchants_combo, 18, 1)
|
|
|
|
# Etch Factor
|
|
self.factor_label = QtWidgets.QLabel('%s:' % _('Etch factor'))
|
|
self.factor_label.setToolTip(
|
|
_("The ratio between depth etch and lateral etch .\n"
|
|
"Accepts real numbers and formulas using the operators: /,*,+,-,%")
|
|
)
|
|
self.factor_entry = NumericalEvalEntry(border_color='#0069A9')
|
|
self.factor_entry.setPlaceholderText(_("Real number or formula"))
|
|
self.factor_entry.setObjectName(_("Etch_factor"))
|
|
|
|
grid0.addWidget(self.factor_label, 19, 0)
|
|
grid0.addWidget(self.factor_entry, 19, 1)
|
|
|
|
# Manual Offset
|
|
self.offset_label = QtWidgets.QLabel('%s:' % _('Offset'))
|
|
self.offset_label.setToolTip(
|
|
_("Value with which to increase or decrease (buffer)\n"
|
|
"the copper features. In microns [um].")
|
|
)
|
|
self.offset_entry = FCDoubleSpinner(callback=self.confirmation_message)
|
|
self.offset_entry.set_precision(self.decimals)
|
|
self.offset_entry.set_range(-9999.9999, 9999.9999)
|
|
self.offset_entry.setObjectName(_("Offset"))
|
|
|
|
grid0.addWidget(self.offset_label, 20, 0)
|
|
grid0.addWidget(self.offset_entry, 20, 1)
|
|
|
|
# Hide the Etchants and Etch factor
|
|
self.etchants_label.hide()
|
|
self.etchants_combo.hide()
|
|
self.factor_label.hide()
|
|
self.factor_entry.hide()
|
|
self.offset_label.hide()
|
|
self.offset_entry.hide()
|
|
|
|
separator_line = QtWidgets.QFrame()
|
|
separator_line.setFrameShape(QtWidgets.QFrame.HLine)
|
|
separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
|
|
grid0.addWidget(separator_line, 22, 0, 1, 2)
|
|
|
|
self.compensate_btn = FCButton(_('Compensate'))
|
|
self.compensate_btn.setToolTip(
|
|
_("Will increase the copper features thickness to compensate the lateral etch.")
|
|
)
|
|
self.compensate_btn.setStyleSheet("""
|
|
QPushButton
|
|
{
|
|
font-weight: bold;
|
|
}
|
|
""")
|
|
grid0.addWidget(self.compensate_btn, 24, 0, 1, 2)
|
|
|
|
self.tools_box.addStretch()
|
|
|
|
# ## Reset Tool
|
|
self.reset_button = QtWidgets.QPushButton(_("Reset Tool"))
|
|
self.reset_button.setToolTip(
|
|
_("Will reset the tool parameters.")
|
|
)
|
|
self.reset_button.setStyleSheet("""
|
|
QPushButton
|
|
{
|
|
font-weight: bold;
|
|
}
|
|
""")
|
|
self.tools_box.addWidget(self.reset_button)
|
|
|
|
self.compensate_btn.clicked.connect(self.on_compensate)
|
|
self.reset_button.clicked.connect(self.set_tool_ui)
|
|
self.ratio_radio.activated_custom.connect(self.on_ratio_change)
|
|
|
|
self.oz_entry.textChanged.connect(self.on_oz_conversion)
|
|
self.mils_entry.textChanged.connect(self.on_mils_conversion)
|
|
|
|
def install(self, icon=None, separator=None, **kwargs):
|
|
AppTool.install(self, icon, separator, shortcut='', **kwargs)
|
|
|
|
def run(self, toggle=True):
|
|
self.app.defaults.report_usage("ToolEtchCompensation()")
|
|
log.debug("ToolEtchCompensation() is running ...")
|
|
|
|
if toggle:
|
|
# if the splitter is hidden, display it, else hide it but only if the current widget is the same
|
|
if self.app.ui.splitter.sizes()[0] == 0:
|
|
self.app.ui.splitter.setSizes([1, 1])
|
|
else:
|
|
try:
|
|
if self.app.ui.tool_scroll_area.widget().objectName() == self.toolName:
|
|
# if tab is populated with the tool but it does not have the focus, focus on it
|
|
if not self.app.ui.notebook.currentWidget() is self.app.ui.tool_tab:
|
|
# focus on Tool Tab
|
|
self.app.ui.notebook.setCurrentWidget(self.app.ui.tool_tab)
|
|
else:
|
|
self.app.ui.splitter.setSizes([0, 1])
|
|
except AttributeError:
|
|
pass
|
|
else:
|
|
if self.app.ui.splitter.sizes()[0] == 0:
|
|
self.app.ui.splitter.setSizes([1, 1])
|
|
|
|
AppTool.run(self)
|
|
self.set_tool_ui()
|
|
|
|
self.app.ui.notebook.setTabText(2, _("Etch Compensation Tool"))
|
|
|
|
def set_tool_ui(self):
|
|
self.thick_entry.set_value(18.0)
|
|
self.ratio_radio.set_value('factor')
|
|
|
|
def on_ratio_change(self, val):
|
|
"""
|
|
Called on activated_custom signal of the RadioSet GUI element self.radio_ratio
|
|
|
|
:param val: 'c' or 'p': 'c' means custom factor and 'p' means preselected etchants
|
|
:type val: str
|
|
:return: None
|
|
:rtype:
|
|
"""
|
|
if val == 'factor':
|
|
self.etchants_label.hide()
|
|
self.etchants_combo.hide()
|
|
self.factor_label.show()
|
|
self.factor_entry.show()
|
|
self.offset_label.hide()
|
|
self.offset_entry.hide()
|
|
elif val == 'etch_list':
|
|
self.etchants_label.show()
|
|
self.etchants_combo.show()
|
|
self.factor_label.hide()
|
|
self.factor_entry.hide()
|
|
self.offset_label.hide()
|
|
self.offset_entry.hide()
|
|
else:
|
|
self.etchants_label.hide()
|
|
self.etchants_combo.hide()
|
|
self.factor_label.hide()
|
|
self.factor_entry.hide()
|
|
self.offset_label.show()
|
|
self.offset_entry.show()
|
|
|
|
def on_oz_conversion(self, txt):
|
|
try:
|
|
val = eval(txt)
|
|
# oz thickness to mils by multiplying with 1.37
|
|
# mils to microns by multiplying with 25.4
|
|
val *= 34.798
|
|
except Exception:
|
|
self.oz_to_um_entry.set_value('')
|
|
return
|
|
self.oz_to_um_entry.set_value(val, self.decimals)
|
|
|
|
def on_mils_conversion(self, txt):
|
|
try:
|
|
val = eval(txt)
|
|
val *= 25.4
|
|
except Exception:
|
|
self.mils_to_um_entry.set_value('')
|
|
return
|
|
self.mils_to_um_entry.set_value(val, self.decimals)
|
|
|
|
def on_compensate(self):
|
|
log.debug("ToolEtchCompensation.on_compensate()")
|
|
|
|
ratio_type = self.ratio_radio.get_value()
|
|
thickness = self.thick_entry.get_value() / 1000 # in microns
|
|
|
|
grb_circle_steps = int(self.app.defaults["gerber_circle_steps"])
|
|
obj_name = self.gerber_combo.currentText()
|
|
|
|
outname = obj_name + "_comp"
|
|
|
|
# Get source object.
|
|
try:
|
|
grb_obj = self.app.collection.get_by_name(obj_name)
|
|
except Exception as e:
|
|
self.app.inform.emit('[ERROR_NOTCL] %s: %s' % (_("Could not retrieve object"), str(obj_name)))
|
|
return "Could not retrieve object: %s with error: %s" % (obj_name, str(e))
|
|
|
|
if grb_obj is None:
|
|
if obj_name == '':
|
|
obj_name = 'None'
|
|
self.app.inform.emit('[ERROR_NOTCL] %s: %s' % (_("Object not found"), str(obj_name)))
|
|
return
|
|
|
|
if ratio_type == 'factor':
|
|
etch_factor = 1 / self.factor_entry.get_value()
|
|
offset = thickness / etch_factor
|
|
elif ratio_type == 'etch_list':
|
|
etchant = self.etchants_combo.get_value()
|
|
if etchant == "CuCl2":
|
|
etch_factor = 0.33
|
|
else:
|
|
etch_factor = 0.25
|
|
offset = thickness / etch_factor
|
|
else:
|
|
offset = self.offset_entry.get_value() / 1000 # in microns
|
|
|
|
try:
|
|
__ = iter(grb_obj.solid_geometry)
|
|
except TypeError:
|
|
grb_obj.solid_geometry = list(grb_obj.solid_geometry)
|
|
|
|
new_solid_geometry = []
|
|
|
|
for poly in grb_obj.solid_geometry:
|
|
new_solid_geometry.append(poly.buffer(offset, int(grb_circle_steps)))
|
|
new_solid_geometry = unary_union(new_solid_geometry)
|
|
|
|
new_options = {}
|
|
for opt in grb_obj.options:
|
|
new_options[opt] = deepcopy(grb_obj.options[opt])
|
|
|
|
new_apertures = deepcopy(grb_obj.apertures)
|
|
|
|
# update the apertures attributes (keys in the apertures dict)
|
|
for ap in new_apertures:
|
|
type = new_apertures[ap]['type']
|
|
for k in new_apertures[ap]:
|
|
if type == 'R' or type == 'O':
|
|
if k == 'width' or k == 'height':
|
|
new_apertures[ap][k] += offset
|
|
else:
|
|
if k == 'size' or k == 'width' or k == 'height':
|
|
new_apertures[ap][k] += offset
|
|
|
|
if k == 'geometry':
|
|
for geo_el in new_apertures[ap][k]:
|
|
if 'solid' in geo_el:
|
|
geo_el['solid'] = geo_el['solid'].buffer(offset, int(grb_circle_steps))
|
|
|
|
# in case of 'R' or 'O' aperture type we need to update the aperture 'size' after
|
|
# the 'width' and 'height' keys were updated
|
|
for ap in new_apertures:
|
|
type = new_apertures[ap]['type']
|
|
for k in new_apertures[ap]:
|
|
if type == 'R' or type == 'O':
|
|
if k == 'size':
|
|
new_apertures[ap][k] = math.sqrt(
|
|
new_apertures[ap]['width'] ** 2 + new_apertures[ap]['height'] ** 2)
|
|
|
|
def init_func(new_obj, app_obj):
|
|
"""
|
|
Init a new object in FlatCAM Object collection
|
|
|
|
:param new_obj: New object
|
|
:type new_obj: ObjectCollection
|
|
:param app_obj: App
|
|
:type app_obj: App_Main.App
|
|
:return: None
|
|
:rtype:
|
|
"""
|
|
new_obj.options.update(new_options)
|
|
new_obj.options['name'] = outname
|
|
new_obj.fill_color = deepcopy(grb_obj.fill_color)
|
|
new_obj.outline_color = deepcopy(grb_obj.outline_color)
|
|
|
|
new_obj.apertures = deepcopy(new_apertures)
|
|
|
|
new_obj.solid_geometry = deepcopy(new_solid_geometry)
|
|
new_obj.source_file = self.app.export_gerber(obj_name=outname, filename=None,
|
|
local_use=new_obj, use_thread=False)
|
|
|
|
self.app.app_obj.new_object('gerber', outname, init_func)
|
|
|
|
def reset_fields(self):
|
|
self.gerber_combo.setRootModelIndex(self.app.collection.index(0, 0, QtCore.QModelIndex()))
|
|
|
|
@staticmethod
|
|
def poly2rings(poly):
|
|
return [poly.exterior] + [interior for interior in poly.interiors]
|
|
# end of file
|