#!/usr/bin/env python # coding: utf-8 # # Compute and show the center of mass for multiple solids # # Usage: # 1. Select one or multiple solids. # 2. Launch the macro. # 3. You'll have a window listing the solids. You can put the density of your # material in different unit systems or choose from predefined materials. # # Options: # * Color the solids according to density. # * Display where is the center of mass. # * Export and import masses, materials and densities (even if it's not a .csv # file from the macro, as soon as there is a column named accordingly). # * Save densities in the document (remove them again when setting material # to "default") # # Credits: # 2018 - 2022: schupin # 2022 - 2023: SyProLei project (Saarland University) # __Name__ = 'CenterOfMass' __Comment__ = 'Compute and show the center of mass for multiple solids' __Author__ = 'chupins, s-quirin' __Version__ = '0.7.3' __Date__ = '2023-09-07' __License__ = 'LGPL-3.0-or-later' __Web__ = 'https://forum.freecad.org/viewtopic.php?f=24&t=31883' __Wiki__ = 'https://wiki.freecad.org/Macro_CenterOfMass' __Icon__ = 'https://wiki.freecad.org/images/a/a6/Macro_CenterOfMass.svg' __Help__ = 'Select one or more bodies and launch' __Status__ = 'Alpha' __Requires__ = 'FreeCAD >= 0.19' __Communication__ = 'https://forum.freecad.org/viewtopic.php?f=24&t=31883' __Files__ = '' # Todo: # - error with draft array of meshes (relevant?) # - App:Link lacks the .getGlobalPlacement() method and childShapes() do not equal, # so .InListRecursive and .OutList is used as a workaround. # @realthunder proposes https://forum.freecad.org/viewtopic.php?p=569083#p569083 # Ideas: # - moments of inertia with arrows that allow the user to see the relative magnitudes import copy import csv import os import math from PySide import QtCore, QtGui # FreeCAD's PySide! import FreeCAD as app import FreeCADGui as gui from FreeCAD import Units # Preferences MAXIMUM_DENSITY = '25000 kg/m^3' # maximum physically meaningful value VALUE_DELIMITER = '\t' # delimiter-separated values format: Tab # FreeCAD: Tools -> Edit Parameters, 2nd parameter of .GetX sets the default MACRO_SETTINGS = 'User parameter:BaseApp/Preferences/Macros/' + __Name__ DEFAULT_DENSITY = app.ParamGet(MACRO_SETTINGS).GetString('Default density', '2500 kg/m^3') DOCKED_WINDOW = app.ParamGet(MACRO_SETTINGS).GetBool('Docked window', True) # False: floating SORT_SELECTION = app.ParamGet(MACRO_SETTINGS).GetBool('Sort selection', False) COLOR_SPHERES = app.ParamGet(MACRO_SETTINGS).GetBool('Color spheres', False) COLOR_SATURAT = app.ParamGet(MACRO_SETTINGS).GetUnsigned('Color saturation', 80) COLORMAP_USER = app.ParamGet(MACRO_SETTINGS).GetString('Matplotlib colormap', 'Spectral_r') GUI_FONT_SIZE = app.ParamGet('User parameter:BaseApp/Preferences/Editor').GetInt('FontSize', 10) GUI_ICON_SIZE = app.ParamGet('User parameter:BaseApp/Preferences/General').GetInt('ToolbarIconSize', 24) MATERIAL_SETTINGS = app.ParamGet('User parameter:BaseApp/Preferences/Mod/Material/Resources') USE_BUILT_IN_MATERIALS = MATERIAL_SETTINGS.GetBool('UseBuiltInMaterials', True) USE_MAT_FROM_CONFIG_DIR = MATERIAL_SETTINGS.GetBool('UseMaterialsFromConfigDir', True) USE_MAT_FROM_CUSTOM_DIR = MATERIAL_SETTINGS.GetBool('UseMaterialsFromCustomDir', True) CUSTOM_MAT_DIR = MATERIAL_SETTINGS.GetString('CustomMaterialsDir', '') # Floating window size preferences: fraction of primary screen's size WINDOW_WIDTH = int(0.2 * QtGui.QGuiApplication.screens()[0].geometry().width()) WINDOW_HEIGHT = int(0.5 * QtGui.QGuiApplication.screens()[0].geometry().height()) if int(app.Version()[1]) < 21: # FreeCAD 0.20 is able to run with Qt 5.9.5 but Qt 5.12 was adopted here if QtCore.QLibraryInfo.version() < QtCore.QVersionNumber(5,12): app.Console.PrintWarning('Qt 5.12 or higher is needed to run\n') app.ParamGet(MACRO_SETTINGS).SetString('Default density', DEFAULT_DENSITY) app.ParamGet(MACRO_SETTINGS).SetBool('Docked window', DOCKED_WINDOW) app.ParamGet(MACRO_SETTINGS).SetBool('Sort selection', SORT_SELECTION) app.ParamGet(MACRO_SETTINGS).SetBool('Color spheres', COLOR_SPHERES) app.ParamGet(MACRO_SETTINGS).SetUnsigned('Color saturation', COLOR_SATURAT) app.ParamGet(MACRO_SETTINGS).SetString('Matplotlib colormap', COLORMAP_USER) g_main_window = gui.getMainWindow() g_font = g_main_window.font() g_font.setPointSize(GUI_FONT_SIZE) g_font_metrics = QtGui.QFontMetrics(g_font) g_str_width = g_font_metrics.horizontalAdvance('_0_000e+00_') g_icon_size = QtCore.QSize(GUI_ICON_SIZE, GUI_ICON_SIZE) g_sel_user = [] # the user list of selected objects g_sel = [] # the valid list of selected objects class CenterofmassDock(QtGui.QDockWidget): """if DOCKED_WINDOW = True""" def __init__(self): super().__init__() self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) # free memory self.setLocale(QtCore.QLocale.English) self.child = CenterofmassWidget(self) self.setWidget(self.child) g_main_window.addDockWidget(QtCore.Qt.RightDockWidgetArea, self) class CenterofmassWindow(QtGui.QMainWindow): """if DOCKED_WINDOW = False""" def __init__(self, parent=g_main_window): super().__init__(parent) # parent: Window stays on top self.setLocale(QtCore.QLocale.English) self.child = CenterofmassWidget(self) self.setCentralWidget(self.child) self.set_position() self.show() def set_position(self): """Set a sensible default position for the window. With FreeCAD's default layout, this will be over the Combo View. """ geom = g_main_window.geometry() xpos = geom.left() + 50 ypos = geom.center().y() - WINDOW_HEIGHT // 2 self.setGeometry(xpos, ypos, WINDOW_WIDTH, WINDOW_HEIGHT) class SolidsWidget(): """Rows in scroll area""" def __init__(self, parent, solid, sol): self.parent = parent self.sol = sol color = solid.ViewObject.ShapeColor self.orgColorFC = color self.orgColorQT = QtGui.QColor.fromRgbF(*color) self.orgTransparency = solid.ViewObject.Transparency self.labelC = QtGui.QLabel(' ', parent) self.label = QtGui.QLabel(solid.Label, parent) self.combo = QtGui.QComboBox(parent) self.spinDens = QtGui.QDoubleSpinBox(parent) self.spinMass = QtGui.QLineEdit(parent) # init properties self.labelC.setStyleSheet( 'QLabel {background-color: %s}' % self.orgColorQT.name()) self.labelC.setMaximumWidth(GUI_ICON_SIZE) self.combo.addItem('custom') self.combo.setMinimumContentsLength(14) # auto size adjustment and truncation d_min, d_max = parent.material_base_range if d_min == d_max: self.combo.addItems(parent.material_base) else: # icons filled according to density relation qs = len(parent.pieIcon) - 1 for m in parent.material_base: q = math.ceil((parent.material_base[m] - d_min) / (d_max - d_min) * qs) self.combo.addItem(QtGui.QIcon(parent.pieIcon[q]), m) self.combo.setItemIcon(1, QtGui.QIcon()) # no icon for 'default' self.combo.insertSeparator(2) # after custom+default mat_name = getattr(g_sel[sol], 'Mat_Name', 'default') mat_density = getattr(g_sel[sol], 'Mat_Density', parent.material_base['default']) # class 'Base.Quantity' for saves with FreeCAD > 0.21, before: class 'str' if hasattr(g_sel[sol], 'Material'): if hasattr(g_sel[sol].Material, 'Material'): # overwrite if Arch Material set mat_name = g_sel[sol].Material.Material['CardName'] mat_density = g_sel[sol].Material.Material['Density'] # class 'str' self.combo.setCurrentText(mat_name) # not found: stays 0=custom self.combo.currentIndexChanged.connect(self.on_comboMaterial_changed) self.init_spinDensity(self.spinDens, Units.Quantity(mat_density), parent.unitForD) self.spinDens.setToolTip( 'density of ' + solid.Label + ' (in ' + parent.unitForD_text + ')') self.spinDens.valueChanged.connect(self.on_spinDensity_changed) self.spinMass.setAlignment(QtCore.Qt.AlignCenter) self.spinMass.setMinimumWidth(g_str_width) self.spinMass.setMaximumWidth(g_str_width + GUI_FONT_SIZE) self.spinMass.editingFinished.connect(self.on_spinMass_edited) # layout grid if sol == 0: searchL = QtGui.QLineEdit(parent, placeholderText='Search...') searchL.setClearButtonEnabled(True) searchL.textEdited.connect(parent.on_searchL_edited) label02 = QtGui.QLabel('Material', parent) label03 = QtGui.QLabel('Density', parent) label04 = QtGui.QLabel('Mass', parent) parent.solidLayout.addWidget(searchL, sol, 1) parent.solidLayout.addWidget(label02, sol, 2) parent.solidLayout.addWidget(label03, sol, 3) parent.solidLayout.addWidget(label04, sol, 4) parent.solidLayout.addWidget(self.labelC, sol+1, 0) parent.solidLayout.addWidget(self.label, sol+1, 1) parent.solidLayout.addWidget(self.combo, sol+1, 2) parent.solidLayout.addWidget(self.spinDens, sol+1, 3) parent.solidLayout.addWidget(self.spinMass, sol+1, 4) def init_spinDensity(self, spin, qty, unitForD): spinBoxDigits = 6 # don't trigger on_spinDensity_changed (e.g. when on_comboUnitDensity_changed) spin.blockSignals(True) spin.setRange(0., Units.Quantity(MAXIMUM_DENSITY).getValueAs(unitForD)) decimals = spinBoxDigits - math.floor(math.fabs(math.log10(spin.maximum()))) spin.setDecimals(decimals) spin.setStepType(QtGui.QAbstractSpinBox.StepType.AdaptiveDecimalStepType) spin.setValue(qty.getValueAs(unitForD)) spin.blockSignals(False) def on_spinDensity_changed(self): self.combo.setCurrentIndex(0) # set to custom self.parent.compute_centerOfMass() def on_spinMass_edited(self): parent = self.parent if self.spinMass.text() == format(parent.masses[self.sol], '.3e'): return # there was no editing v_ = QtGui.QDoubleValidator(0., 9.999e99, 3) if v_.validate(self.spinMass.text(),0)[0] == QtGui.QValidator.State.Invalid: self.spinMass.undo() if v_.validate(self.spinMass.text(),0)[0] == QtGui.QValidator.State.Invalid: self.spinMass.setText('0.000e00') return # input not in scientific notation volumeInUnit = parent.convert_volume(parent.volumes[self.sol]) dens = float(self.spinMass.text()) / volumeInUnit self.spinDens.setValue(dens) # trigger on_spinDensity_changed def on_comboMaterial_changed(self, newIndex): materialName = self.combo.currentText() qty = self.parent.material_base.get(materialName, 0) if qty: materialDensity = Units.Quantity(qty).getValueAs(self.parent.unitForD) self.spinDens.blockSignals(True) # don't trigger on_spinDensity_changed self.spinDens.setValue(materialDensity) self.spinDens.blockSignals(False) self.parent.compute_centerOfMass() class CenterofmassWidget(QtGui.QWidget): """This is the widget which does almost all of the work. Widgets don't have close boxes, so closing is dealt with in CenterofmassWindow. """ def __init__(self, parent): super().__init__(parent) self.setObjectName(__Name__) parent.setWindowTitle(__Name__ + ' ' + __Version__) parent.setFont(g_font) self.doc = app.activeDocument() self.material_base = {} self.solid_count = 0 objs = self.valid_selection(gui.Selection.getSelection()) self.init_UI() if self.solid_count <= 0: self.setDisabled(True) self.startup_dialog() else: self.init_solids(objs) def startup_dialog(self): """Error dialog allowing a new selection""" msg = 'Select a valid object (a solid or a mesh) first.' app.Console.PrintError(msg + '\n') diag = QtGui.QMessageBox(QtGui.QMessageBox.Critical, 'Error', msg, parent=g_main_window) diag.setModal(False) diag.setStandardButtons(QtGui.QMessageBox.Ok | QtGui.QMessageBox.Cancel) diag.finished.connect(self.on_startup_dialog_finished) diag.show() def on_startup_dialog_finished(self, result): if result == QtGui.QMessageBox.Ok: objs = self.valid_selection(gui.Selection.getSelection()) if self.solid_count <= 0: self.startup_dialog() else: self.init_solids(objs) self.setDisabled(False) else: self.parentWidget().close() def init_UI(self): """Lay out the interactive elements""" # main layout layout = QtGui.QVBoxLayout(self) # titleLayout toPreferences = QtGui.QPushButton(QtGui.QIcon(':/icons/Std_DlgParameter.svg'),'') toPreferences.setToolTip('Preferences -> Macros') toPreferences.clicked.connect(self.on_pushButton_toPreferences) editMaterial = QtGui.QPushButton(QtGui.QIcon(':/icons/Arch_Material_Group.svg'),'') editMaterial.setToolTip('Edit Material list') editMaterial.clicked.connect(self.on_pushButton_editMaterial) label_comboUnit = QtGui.QLabel('Density:') label_comboUnit.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) comboUnitDensity = QtGui.QComboBox(toolTip='Unit of density') comboUnitDensity.addItems(['kg/m³', 'g/dm³', 'g/cm³', 'mg/mm³', 'oz/in³', 'lb/in³', 'lb/ft³', 'lb/yd³']) comboUnitDensity.currentTextChanged.connect(self.on_comboUnitDensity_changed) # Get units set as preference self.store_prefered_units(comboUnitDensity.currentText()) label_defaultDensity = QtGui.QLabel('Default value:') label_defaultDensity.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) self.defaultDensitySpin = QtGui.QDoubleSpinBox(self) SolidsWidget.init_spinDensity(self, self.defaultDensitySpin, Units.Quantity(DEFAULT_DENSITY), self.unitForD) self.defaultDensitySpin.setMinimum(math.pow(10, -self.defaultDensitySpin.decimals())) self.defaultDensitySpin.setToolTip( 'set default density (in ' + self.unitForD_text + ')') self.defaultDensitySpin.valueChanged.connect(self.on_spinDefaultDensity_changed) self.material_base['default'] = Units.Quantity(DEFAULT_DENSITY) iconPath = ':/icons/measure/Part_Measure_Step_Active.svg' allToDefaultDensity = QtGui.QPushButton(QtGui.QIcon(iconPath),' to all') allToDefaultDensity.setToolTip('Set all solids to default density') allToDefaultDensity.clicked.connect(self.on_pushButton_allToDefaultDensity) titleLayoutBox = QtGui.QGroupBox('Preferences') titleLayout = QtGui.QHBoxLayout() titleLayout.addWidget(toPreferences) titleLayout.addWidget(editMaterial) titleLayout.addWidget(label_comboUnit) titleLayout.addWidget(comboUnitDensity) titleLayout.addSpacing(GUI_FONT_SIZE) titleLayout.addWidget(label_defaultDensity) titleLayout.addWidget(self.defaultDensitySpin) titleLayout.addWidget(allToDefaultDensity) titleLayoutBox.setLayout(titleLayout) layout.addWidget(titleLayoutBox) # solidGroupBox self.load_materials() # load material cards solidGroupBox = QtGui.QWidget() self.solidLayout = QtGui.QGridLayout(solidGroupBox) self.scroll = QtGui.QScrollArea() self.scroll.setWidget(solidGroupBox) self.scroll.setWidgetResizable(True) self.found_labels = [] # for search # custom icon set of pies for SolidsWidget().combo self.pieIcon = [] drawSize = g_font_metrics.height() * 2 drawArea = QtCore.QRect(0, 0, drawSize-8, drawSize) drawArea.adjust(2, 6, -2, -6) # padding for q in range(5): pixmap = QtGui.QPixmap(drawSize-8, drawSize) pixmap.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(pixmap) painter.drawEllipse(drawArea) if q in range(1,5): painter.setBrush(QtGui.QBrush(QtCore.Qt.black)) painter.drawPie(drawArea, (1-q)*90*16, q*90*16) # 1/16th of a degree counterclockwise, zero degrees at the 3 o'clock position painter.end() # needed in Python self.pieIcon.append(pixmap) # buttonGroupBox newSelection = QtGui.QPushButton('New') newSelection.setToolTip('Get solids from selection') newSelection.setIcon(QtGui.QIcon(':/icons/LinkSelect.svg')) newSelection.setIconSize(g_icon_size) newSelection.clicked.connect(self.on_pushButton_newSelection) update = QtGui.QPushButton('Update') update.setToolTip('Update solids from document') update.setIcon(QtGui.QIcon(':/icons/view-refresh.svg')) update.setIconSize(g_icon_size) update.clicked.connect(self.on_pushButton_update) save = QtGui.QPushButton('Save') save.setToolTip('Copy material property to your document model data') save.setIcon(QtGui.QIcon(':/icons/Std_MergeProjects.svg')) save.setIconSize(g_icon_size) save.clicked.connect(self.on_pushButton_Save) export = QtGui.QPushButton('Export') export.setToolTip('Export values to a .csv file') export.setIcon(QtGui.QIcon(':/icons/Std_SaveCopy.svg')) export.setIconSize(g_icon_size) export.clicked.connect(self.on_pushButton_Export) readDensities = QtGui.QPushButton('Import') readDensities.setToolTip('Import masses, materials or densities from a file') readDensities.setIcon(QtGui.QIcon(':/icons/Std_Import.svg')) readDensities.setIconSize(g_icon_size) readDensities.clicked.connect(self.on_pushButton_Import) buttonGroupBox = QtGui.QWidget() buttonLayout = QtGui.QHBoxLayout(buttonGroupBox) buttonLayout.addWidget(newSelection) buttonLayout.addWidget(update) buttonLayout.addWidget(save) buttonLayout.addWidget(export) buttonLayout.addWidget(readDensities) margin = buttonLayout.contentsMargins() buttonLayout.setContentsMargins(0, margin.top()/2, 0, 0) self.mainGroupBox = QtGui.QGroupBox('Selected solids: 0') mainGroupLayout = QtGui.QVBoxLayout() mainGroupLayout.addWidget(self.scroll) mainGroupLayout.addWidget(buttonGroupBox) self.mainGroupBox.setLayout(mainGroupLayout) layout.addWidget(self.mainGroupBox) # viewGroupBox showCoM = QtGui.QCheckBox(toolTip='Show center of mass') if self.doc.getObject('CenterOfMass'): showCoM.setChecked(True) showCoM.setIcon(QtGui.QIcon(':/icons/Std_ToggleVisibility.svg')) showCoM.setIconSize(g_icon_size) showCoM.stateChanged.connect(self.on_stateChanged_showCoM) self.changeRadius = QtGui.QSlider(QtGui.Qt.Horizontal) self.changeRadius.setToolTip('Change radius of spheres') self.changeRadius.setEnabled(False) self.changeRadius.setMaximum(49) self.changeRadius.valueChanged.connect(self.on_slideButton_changeRadius) self.checkColorify = QtGui.QCheckBox(toolTip='Color shapes depending on density') self.checkColorify.setIcon(QtGui.QIcon(':/icons/Std_RandomColor.svg')) self.checkColorify.setIconSize(g_icon_size) self.checkColorify.stateChanged.connect(self.on_stateChanged_Colorify) self.comboColormap = QtGui.QComboBox(toolTip='Colormap to color shapes depending on density') self.comboColormap.addItem('Traffic') # built-in default # See https://matplotlib.org/3.3.3/tutorials/colors/colormaps.html self.comboColormap.addItems(['RdYlGn_r','autumn_r','cividis','coolwarm', 'Greys', 'Greys_r','summer','Wistia']) self.comboColormap.addItem(COLORMAP_USER) self.comboColormap.currentTextChanged.connect(self.on_comboColormap_changed) viewLayout = QtGui.QHBoxLayout() viewLayout.addWidget(showCoM) viewLayout.addWidget(self.changeRadius) viewLayout.addSpacing(GUI_FONT_SIZE) viewLayout.addWidget(self.checkColorify) viewLayout.addWidget(self.comboColormap) viewGroupBox = QtGui.QGroupBox('View') viewGroupBox.setLayout(viewLayout) layout.addWidget(viewGroupBox) # cdgGroupBox label_CdG = QtGui.QLabel('Center of mass') self.resultCdG = [] for axis in range(3): self.resultCdG.append(QtGui.QLineEdit(self)) self.resultCdG[axis].setObjectName('center of mass') self.resultCdG[axis].setReadOnly(True) toClipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'),'') toClipboard.setToolTip(f'Copy to clipboard ({Units.listSchemas(Units.getSchema())})') toClipboard.setFlat(True) toClipboard.clicked.connect(self.on_pushButton_copyToClipboardCdG) cdgLayout = QtGui.QHBoxLayout() cdgLayout.addWidget(label_CdG) for axis in range(3): cdgLayout.addWidget(self.resultCdG[axis]) cdgLayout.addWidget(toClipboard) cdgGroupBox = QtGui.QGroupBox() cdgGroupBox.setLayout(cdgLayout) layout.addWidget(cdgGroupBox) # totalGroupBox label_Mass = QtGui.QLabel('Mass') self.resultMass = QtGui.QLineEdit(self) self.resultMass.setObjectName('total weight') self.resultMass.setReadOnly(True) label_Density = QtGui.QLabel('Density') self.resultDensity = QtGui.QLineEdit(self) self.resultDensity.setReadOnly(True) toClipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'),'') toClipboard.setToolTip(f'Copy to clipboard ({Units.listSchemas(Units.getSchema())})') toClipboard.setFlat(True) toClipboard.clicked.connect(self.on_pushButton_copyToClipboardTotal) totalLayout = QtGui.QHBoxLayout() totalLayout.addWidget(label_Mass) totalLayout.addWidget(self.resultMass) totalLayout.addSpacing(GUI_FONT_SIZE) totalLayout.addWidget(label_Density) totalLayout.addWidget(self.resultDensity) totalLayout.addWidget(toClipboard) totalGroupBox = QtGui.QGroupBox('Total') totalGroupBox.setLayout(totalLayout) layout.addWidget(totalGroupBox) qApp = QtGui.QApplication.instance() qApp.focusChanged.connect(self.on_focusChanged) # emits on each focus change in FreeCAD def init_solids(self, objs): """Construct new items and compute""" self.find_all_centerOfMass(objs) for sol in range(self.solid_count): self.solids[sol] = SolidsWidget( parent=self, solid=g_sel[sol], sol=sol ) self.mainGroupBox.setTitle('Selected solids: ' + str(self.solid_count)) self.compute_centerOfMass(True) def on_searchL_edited(self, text): """Highlight all findings and scroll to first or reset""" if self.found_labels: # Reset label highlighting for label in self.found_labels: label.setFrameStyle(QtGui.QFrame.NoFrame) self.found_labels = [] if text == '': self.scroll.ensureWidgetVisible(self.solids[0].label) return for selfsol in self.solids: if text.lower() in selfsol.label.text().lower(): self.found_labels.append(selfsol.label) selfsol.label.setFrameShape(QtGui.QFrame.StyledPanel) if self.found_labels: self.scroll.ensureWidgetVisible(self.found_labels[0]) def on_focusChanged(self, oldWidget, nowWidget): """Toggle appearance of solid being edited""" if 'QListView' in (type(oldWidget).__name__, type(nowWidget).__name__): # skip if focus changes between combo box and its dropdown list return for w in (oldWidget, nowWidget): # deleted (None) objects can be passed if w is not None and w.parent() == self.solidLayout.parentWidget(): idx = self.solidLayout.indexOf(w) if idx > 0: row = self.solidLayout.getItemPosition(idx)[0] - 1 # minus header if gui.Selection.isSelected(g_sel[row]): gui.Selection.removeSelection(g_sel[row]) else: gui.Selection.addSelection(g_sel[row]) def valid_selection(self, _sel): """Get valid objects (Shape, Mesh) from selection""" def sort_selection_by_label(s_): return s_.Label def sort_selection_by_tree(s_): return self.tree_list.index(s_.Label) global g_sel_user g_sel_user = copy.copy(_sel) # Add contents of group objects 'groupObjs' (breadth-first search). # For PartDesign objects a Group contains recursive features treated separately. groupObjs = ('App::DocumentObjectGroup', 'App::GeometryPython') i_ = 0 while i_ < len(_sel): if hasattr(_sel[i_], 'Group') and _sel[i_].TypeId in groupObjs: _sel.extend(_sel[i_].Group) del _sel[i_] elif hasattr(_sel[i_], 'Group') and _sel[i_].TypeId == 'App::Part': for gp in _sel[i_].Group: # Nested App:Part are considered at this place: if gp.TypeId == 'App::Part': _sel.append(gp) # App:Part can be shapeless container of meshes (e.g. .stl's): elif gp.TypeId == 'Mesh::Feature': _sel.append(gp) i_ += 1 # where to look at the next run else: i_ += 1 # create valid selection list vsel = [] for s_ in _sel: if hasattr(s_, 'Shape') and s_.Shape.Volume: if s_.TypeId == 'App::Part': # because contains bodies for cs in s_.Shape.childShapes(False,False): # childShapes(False,False): ignore placement of parent # get first match from OutList (contains different object types) for ot in s_.OutList: if hasattr(ot, 'Shape') and ot.Shape.isEqual(cs): # nested App:Part and App:Link in App:Part not match here vsel.append(ot) break # App:Link in App:Part for ot in s_.OutList: if ot.TypeId == 'App::Link': vsel.append(ot) elif s_.TypeId == 'Part::FeaturePython' and hasattr(s_, 'IfcType'): # e.g. Arch Wall/Structure, because can contain childs vsel.append(s_) for it in s_.InList[1:]: # include childs (Windows etc.), parent is first InList if it.TypeId == 'Part::FeaturePython' and it.Shape.Volume: vsel.append(it) else: vsel.append(s_) elif hasattr(s_, 'Mesh') and s_.Mesh.Volume: vsel.append(s_) # remove double items (when groupObjs and containing item selected) vsel = list(dict.fromkeys(vsel)) # sort valid selection list or match up with tree view if SORT_SELECTION: vsel.sort(key=sort_selection_by_label) else: tree = g_main_window.findChild(QtGui.QTreeWidget) iterator = QtGui.QTreeWidgetItemIterator(tree, QtGui.QTreeWidgetItemIterator.Editable) self.tree_list = [i_.value().text(0) for i_ in list(iterator)] if self.tree_list: vsel.sort(key=sort_selection_by_tree) else: # e.g. some FreeCAD 0.21 weekly builds app.Console.PrintWarning('Could not take over the sorting of the tree view\n') vsel.sort(key=sort_selection_by_label) # create valid objects list (e.g. shapes) import Part objs = [] for i_, s_ in enumerate(vsel): if hasattr(s_, 'Shape'): # local placement correction (transform to global coordinate system) o_ = Part.getShape(s_) # copy shape for transformation if callable(getattr(s_, 'getGlobalPlacement', None)): o_.Placement = s_.getGlobalPlacement() else: # e.g. App::Link has no method getGlobalPlacement if s_.InList: for it in s_.InListRecursive: o_.Placement = o_.Placement.multiply(it.Placement) objs.append(o_) elif hasattr(s_, 'Mesh'): objs.append(s_.Mesh) # change vsel entry for special object cases if s_.TypeId == 'Part::FeaturePython' and hasattr(s_, 'ArrayType'): # e.g. Draft Arrays, because no ShapeColor etc. in ViewObject vsel[i_] = s_.OutList[0] if len(_sel) > len(vsel): app.Console.PrintWarning('Ignored invalid object from selection\n') if objs: global g_sel g_sel = vsel self.solid_count = len(objs) return objs def on_pushButton_newSelection(self): initial = self.checkColorify.checkState() self.checkColorify.setCheckState(QtCore.Qt.Unchecked) # preserve SolidsWidget.orgColor self.doc = app.activeDocument() self.update_selection(gui.Selection.getSelection()) self.checkColorify.setCheckState(initial) def on_pushButton_update(self): initial = self.checkColorify.checkState() self.checkColorify.setCheckState(QtCore.Qt.Unchecked) # preserve SolidsWidget.orgColor self.update_selection(g_sel_user) self.checkColorify.setCheckState(initial) def update_selection(self, _sel): try: self.set_objects_transparent(False) except: pass objs = self.valid_selection(_sel) if not objs: return # safe way to remove all items from layout while self.solidLayout.count(): child = self.solidLayout.takeAt(0) if child.widget(): child.widget().deleteLater() self.init_solids(objs) if self.changeRadius.isEnabled(): self.draw_centerOfMass() def load_materials(self): """Load density from material cards, get resource paths from FEM preferences""" resources = [] if USE_BUILT_IN_MATERIALS: # FreeCAD.getResourceDir() returns inconsistent path separators # (https://forum.freecad.org/viewtopic.php?t=32036) resources.append(os.path.join( os.path.normpath(app.getResourceDir()), "Mod", "Material", "StandardMaterial") ) if USE_MAT_FROM_CONFIG_DIR: resources.append(os.path.join(app.ConfigGet("UserAppData"), "Material")) if USE_MAT_FROM_CUSTOM_DIR and CUSTOM_MAT_DIR: resources.append(CUSTOM_MAT_DIR) print('Looking for material cards according to', 'User parameter:BaseApp/Preferences/Mod/Material/Resources') for path in resources: print(' ' + path) # cards found later with same name will override previous ones # Read material cards import importFCMat mat_cards = {} # all material cards self.material_cards = {} # with valid density for p in resources: if os.path.exists(p): for f in sorted(os.listdir(p)): b, e = os.path.splitext(f) if e.upper() == ".FCMAT": mat_cards[b] = os.path.join(p, f) for mat_name in sorted(mat_cards): try: d = importFCMat.read(mat_cards[mat_name]).get('Density') if (len(d) > 1 and Units.Quantity(d).Value > 0): self.material_base[mat_name] = Units.Quantity(d) self.material_cards[mat_name] = mat_cards[mat_name] except: pass qtys = self.material_base.values() self.material_base_range = (min(qtys), max(qtys)) def on_pushButton_editMaterial(self): import MaterialEditor material_base_old = copy.copy(self.material_base) MaterialEditor.openEditor() self.load_materials() # add combo items for newly created materials new_materials = [m for m in self.material_base if m not in material_base_old] for selfsol in self.solids: selfsol.combo.addItems(new_materials) def store_prefered_units(self, txt): """Get units set as preference""" self.unitForD_text = txt self.unitForD = self.unitForD_text.replace('³', '^3') # density self.unitForL = self.unitForD_text.split('/')[1].rstrip('³') # length self.unitForM = self.unitForD_text.split('/')[0] # mass self.unitForV = self.unitForD.split('/')[1] # volume def convert_length(self, length): """Convert length from internal FreeCAD to preference unit""" pq = Units.parseQuantity return float(pq(f'{length} mm') / pq(self.unitForL)) def convert_volume(self, volume): """Convert volume from internal FreeCAD to preference unit""" pq = Units.parseQuantity return float(pq(f'{volume} mm^3') / pq(self.unitForV)) def on_comboUnitDensity_changed(self, newText): unitForD_prev = self.unitForD self.store_prefered_units(newText) for selfsol in self.solids: qty = str(selfsol.spinDens.value()) + ' ' + unitForD_prev selfsol.init_spinDensity(selfsol.spinDens, Units.Quantity(qty), self.unitForD) selfsol.spinDens.setToolTip( 'density of ' + selfsol.label.text() + ' (in ' + self.unitForD_text + ')') qty = self.material_base.get('default', 0) SolidsWidget.init_spinDensity(self, self.defaultDensitySpin, Units.Quantity(qty), self.unitForD) self.defaultDensitySpin.setToolTip('set default density (in ' + self.unitForD_text + ')') self.compute_centerOfMass() def on_spinDefaultDensity_changed(self, newValue): qty = str(newValue) + ' ' + self.unitForD self.material_base['default'] = Units.Quantity(qty) self.compute_centerOfMass(False) for selfsol in self.solids: if selfsol.combo.currentText() == 'default': selfsol.on_comboMaterial_changed(1) # trigger update of "default" self.compute_centerOfMass(True) def on_pushButton_allToDefaultDensity(self): self.compute_centerOfMass(False) for selfsol in self.solids: selfsol.combo.setCurrentText('default') self.compute_centerOfMass(True) def find_all_centerOfMass(self, objs): """Find all center of mass (CoMs) depending on the type of object.""" import DraftVecUtils self.solids = [0] * self.solid_count self.volumes = [0] * self.solid_count self.masses = [0] * self.solid_count self.CoMs = [app.Vector(0, 0, 0)] * self.solid_count # function is slower than valid_selection and compute_centerOfMass # because of .Volume and .CenterOfMass progress_bar = app.Base.ProgressIndicator() progress_bar.start('Finding center of mass ...', self.solid_count) for sol in range(self.solid_count): self.volumes[sol] = objs[sol].Volume if hasattr(objs[sol], 'CenterOfGravity'): # FreeCAD >= 0.20 self.CoMs[sol] = objs[sol].CenterOfGravity elif hasattr(objs[sol], 'CenterOfMass'): # FreeCAD 0.19 self.CoMs[sol] = objs[sol].CenterOfMass elif hasattr(objs[sol], 'Solids'): for array_sol in objs[sol].Solids: if hasattr(array_sol, 'CenterOfGravity'): self.CoMs[sol] += array_sol.CenterOfGravity else: self.CoMs[sol] += array_sol.CenterOfMass self.CoMs[sol] /= objs[sol].Solids.__len__() # estimate CoM of a mesh elif hasattr(objs[sol], 'Points'): _CoM = [0,0,0] for f in objs[sol].Facets: currentVolume = (f.Points[0][0]*f.Points[1][1]*f.Points[2][2] - f.Points[0][0]*f.Points[2][1]*f.Points[1][2] - f.Points[1][0]*f.Points[0][1]*f.Points[2][2] + f.Points[1][0]*f.Points[2][1]*f.Points[0][2] + f.Points[2][0]*f.Points[0][1]*f.Points[1][2] - f.Points[2][0]*f.Points[1][1]*f.Points[0][2]) / 6. for ax in range(3): _CoM[ax] += ((f.Points[0][ax] + f.Points[1][ax] + f.Points[2][ax]) / 4. ) * currentVolume for ax in range(3): _CoM[ax] /= self.volumes[sol] self.CoMs[sol] = app.Vector(*_CoM) # Calculate BoundBox of all objects if sol == 0: self.boundBox = objs[sol].BoundBox else: self.boundBox = self.boundBox.united(objs[sol].BoundBox) progress_bar.next() progress_bar.stop() def compute_centerOfMass(self, enabled=None): """Compute joint center of mass from all objects if enabled. Block by setting to False.""" if enabled is not None: self.compute_enabled = enabled if not self.compute_enabled: return self.massTot = 0. self.volTot = 0. self.TotalCoM = app.Vector(0,0,0) for sol in range(self.solid_count): volumeInUnit = self.convert_volume(self.volumes[sol]) self.masses[sol] = volumeInUnit * self.solids[sol].spinDens.value() if self.masses[sol] == 0: continue self.massTot += self.masses[sol] self.volTot += volumeInUnit if self.massTot == 0: error_message('All masses were set to zero. Last one reset to default.') self.solids[-1].combo.setCurrentText('default') return for sol in range(self.solid_count): self.TotalCoM += 1 / self.massTot * self.masses[sol] * self.CoMs[sol] # output for sol, selfsol in enumerate(self.solids): selfsol.spinMass.setText(f'{self.masses[sol]:.3e}') selfsol.spinMass.setToolTip(f'mass of {selfsol.label.text()} (in {self.unitForM})') for axis in range(3): self.resultCdG[axis].setText(f'{self.convert_length(self.TotalCoM[axis]):.6}') self.resultCdG[0].setToolTip(f'center of mass X (in {self.unitForL})') self.resultCdG[1].setToolTip(f'center of mass Y (in {self.unitForL})') self.resultCdG[2].setToolTip(f'center of mass Z (in {self.unitForL})') self.resultMass.setText(f'{self.massTot:.6}') self.resultMass.setToolTip(f'total weight (in {self.unitForM})') self.resultDensity.setText(f'{self.massTot/self.volTot :.6}') self.resultDensity.setToolTip(f'total density (in {self.unitForD_text})') if self.doc.getObject('CenterOfMass'): self.draw_centerOfMass() if self.checkColorify.isChecked(): self.colorify() def draw_centerOfMass(self): boundBoxL = (self.boundBox.XLength, self.boundBox.YLength, self.boundBox.ZLength) self.doc = app.activeDocument() # it is possible to draw in a different document try: CoMObjs = self.doc.getObject('CenterOfMass') CoMObjs.removeObjectsFromDocument() # remove childs except: CoMObjs = self.doc.addObject('App::DocumentObjectGroup', 'CenterOfMass') # Bounding box of valid selection BBoxSolid = self.doc.addObject('Part::Box','CoMBBoxOfSel') BBoxSolid.Placement.Base = self.boundBox.Center.sub(app.Vector(boundBoxL).multiply(0.5)) BBoxSolid.Length = boundBoxL[0] BBoxSolid.Width = boundBoxL[1] BBoxSolid.Height = boundBoxL[2] BBoxSolid.ViewObject.BoundingBox = True BBoxSolid.ViewObject.Transparency = 90 BBoxSolid.ViewObject.ShapeColor = (0.5, 0.5, 0.5) BBoxSolid.ViewObject.Visibility = False CoMObjs.addObject(BBoxSolid) # Local coordinate system at center of masses lcs = self.doc.addObject('PartDesign::CoordinateSystem', 'CoMLCS') lcs.Placement = app.Placement(self.TotalCoM, app.Rotation(0,0,0)) CoMObjs.addObject(lcs) # Sphere to represent the center of masses sphere = self.doc.addObject('Part::Sphere', 'CoMTotal') sphere.Placement.Base = self.TotalCoM sphere.ViewObject.ShapeColor = (0.6, 0.0, 0.0) sphere.ViewObject.LineWidth = 1.0 CoMObjs.addObject(sphere) # Spheres for all center of mass if self.solid_count > 1: for sol in range(self.solid_count): if self.masses[sol] == 0: continue sphere = self.doc.addObject('Part::Sphere', 'CoM_' + g_sel[sol].Name) sphere.Label = 'CoM_' + g_sel[sol].Label sphere.Placement.Base = self.CoMs[sol] if COLOR_SPHERES: sphere.ViewObject.ShapeColor = self.solids[sol].orgColorFC else: sphere.ViewObject.ShapeColor = (1.0, 1.0, 1.0) sphere.ViewObject.LineWidth = 1.0 CoMObjs.addObject(sphere) # Planes with center of mass and size of boundBox cplane_name = ('CoMPlaneYZ', 'CoMPlaneXZ', 'CoMPlaneXY') cplane_norm = (app.Vector(1.,0.,0.), app.Vector(0.,1.,0.), app.Vector(0.,0.,1.)) cplane_rot = (app.Rotation(0,-90, 0), app.Rotation(0, 0, 90), app.Rotation(0, 0, 0)) cplane_lewi =((boundBoxL[2],boundBoxL[1]), (boundBoxL[0],boundBoxL[2]), (boundBoxL[0],boundBoxL[1])) for axis in range(3): plane = self.doc.addObject('Part::Plane', cplane_name[axis]) plane.Length = cplane_lewi[axis][0] plane.Width = cplane_lewi[axis][1] plane.Placement = app.Placement(self.TotalCoM, cplane_rot[axis]) for axi2 in range(3): plane.Placement.move(cplane_norm[axi2] * boundBoxL[axi2] /-2.) plane.Placement.move( cplane_norm[axis] * boundBoxL[axis] /2.) color = list(cplane_norm[axis]) plane.ViewObject.LineColor = (*color, 0.) # rgba plane.ViewObject.LineWidth = 1.0 plane.ViewObject.Transparency = 100 CoMObjs.addObject(plane) self.draw_update_sphere_radius() self.set_objects_transparent(True) self.doc.recompute() self.changeRadius.setEnabled(True) def draw_update_sphere_radius(self): boundBoxL = (self.boundBox.XLength, self.boundBox.YLength, self.boundBox.ZLength) radiusCOM = (1+self.changeRadius.value())/100. # Sphere to represent the center of masses sphere = self.doc.getObject('CoMTotal') if hasattr(sphere, 'Radius'): sphere.Radius = radiusCOM * max(boundBoxL) # Spheres for all center of mass # Radius of the sphere is linked to the mass of the solid: R = (m_sol/m_tot)^1/3 for sol in range(self.solid_count): sphere = self.doc.getObject('CoM_' + g_sel[sol].Name) if hasattr(sphere, 'Radius'): sphere.Radius = radiusCOM * max(boundBoxL) * math.pow(self.masses[sol]/self.massTot, 1./3.) self.doc.recompute() def set_objects_transparent(self, transparent): if transparent: for sol in range(self.solid_count): g_sel[sol].ViewObject.Transparency = 25 else: for sol in range(self.solid_count): g_sel[sol].ViewObject.Transparency = self.solids[sol].orgTransparency def on_stateChanged_showCoM(self, state): if state == QtCore.Qt.Checked: self.draw_centerOfMass() else: self.changeRadius.setEnabled(False) try: self.set_objects_transparent(False) self.doc.getObject('CenterOfMass').removeObjectsFromDocument() self.doc.removeObject('CenterOfMass') except: pass def on_slideButton_changeRadius(self): self.draw_update_sphere_radius() def on_stateChanged_Colorify(self, state): if state == QtCore.Qt.Checked: self.colorify() else: for sol, selfsol in enumerate(self.solids): name = g_sel[sol].Name self.doc.getObject(name).ViewObject.ShapeColor = selfsol.orgColorFC if COLOR_SPHERES and self.changeRadius.isEnabled(): self.doc.getObject('CoM_' + name).ViewObject.ShapeColor = selfsol.orgColorFC selfsol.spinDens.setPalette(QtGui.QPalette()) # reset to normal def on_comboColormap_changed(self, newText): if self.checkColorify.isChecked(): self.colorify() def colorify(self): cmName = self.comboColormap.currentText() if cmName != 'Traffic': import matplotlib.cm cm = matplotlib.cm.get_cmap(cmName) densities = [selfsol.spinDens.value() for selfsol in self.solids] maxD = max(densities) minD = min([i_ for i_ in densities if i_ != 0]) # min without zeros for sol, selfsol in enumerate(self.solids): name = g_sel[sol].Name if self.masses[sol] == 0: self.doc.getObject(name).ViewObject.ShapeColor = selfsol.orgColorFC selfsol.spinDens.setPalette(QtGui.QPalette()) # reset to normal continue if maxD == minD: drel = 0 # default to low color else: drel = (densities[sol]-minD) / (maxD-minD) if cmName == 'Traffic': # density value to hsv color range green to red drel = math.acos(1-2*drel) / math.pi # stretch yellow (sigmoid-like) h = 120 * (1-drel) / 360 color = QtGui.QColor.fromHsvF(h, 1, 1) else: color = QtGui.QColor.fromRgbF(*cm(drel)) color.setHsvF(color.hueF(), COLOR_SATURAT/100, color.valueF()) # skips if s > 1 self.doc.getObject(name).ViewObject.ShapeColor = color.getRgbF() if COLOR_SPHERES and self.changeRadius.isEnabled(): self.doc.getObject('CoM_' + name).ViewObject.ShapeColor = color.getRgbF() pal = QtGui.QPalette() pal.setColor(QtGui.QPalette.Base, color) lum = color.getRgbF() # https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum#dfn-relative-luminance for c in lum: c = c/12.92 if c <= 0.04045 else math.pow((c+0.055)/1.055, 2.4) if 0.2126*lum[0] + 0.7152*lum[1] + 0.0722*lum[2] < 0.5: pal.setColor(QtGui.QPalette.Text, QtCore.Qt.white) selfsol.spinDens.setPalette(pal) def on_pushButton_toPreferences(self): gui.runCommand('Std_DlgParameter',0) def on_pushButton_copyToClipboardCdG(self): """Copy Vector to clipboard (in FreeCAD Standard unit system)""" string = VALUE_DELIMITER.join(str(self.TotalCoM[axis]) for axis in range(3)) QtGui.QGuiApplication.clipboard().setText(string) def on_pushButton_copyToClipboardTotal(self): """Copy total mass and density to clipboard (in FreeCAD Standard unit system)""" string = VALUE_DELIMITER.join((str(self.massTot), str(self.massTot/self.volTot))) QtGui.QGuiApplication.clipboard().setText(string) def on_pushButton_Save(self): for sol in range(self.solid_count): mat_selected = self.solids[sol].combo.currentText() if mat_selected == 'default': # remove properties set by this macro g_sel[sol].removeProperty('Mat_Name') g_sel[sol].removeProperty('Mat_Density') else: # Material Name tip = ('Custom', 'set by CenterOfMass Macro') if not hasattr(g_sel[sol], 'Mat_Name'): g_sel[sol].addProperty('App::PropertyString', 'Mat_Name', *tip) g_sel[sol].Mat_Name = mat_selected # Density if not hasattr(g_sel[sol], 'Mat_Density'): if int(app.Version()[1]) <= 21: g_sel[sol].addProperty('App::PropertyString', 'Mat_Density', *tip) else: # 'App::PropertyDensity' available since FreeCAD 0.21 # -> cannot be opened in FreeCAD < 0.21 so start using with the next version g_sel[sol].addProperty('App::PropertyDensity', 'Mat_Density', *tip) g_sel[sol].Mat_Density = str(self.solids[sol].spinDens.value()) + ' ' + self.unitForD def on_pushButton_Export(self): """Export values in a delimiter-separated table (default: Tab)""" encoding, delimiter = 'utf-8', VALUE_DELIMITER dstr = 'Tab' if VALUE_DELIMITER == '\t' else 'User-delimiter' fileFilter = (f'{dstr}-separated CSV (*.csv)',f'{dstr}-separated Text (*.txt)') fileName, selectedFilter = QtGui.QFileDialog.getSaveFileName(self, 'Export values', os.path.expanduser('~'), ';;'.join(fileFilter)) if fileName == '': app.Console.PrintWarning('No file saved \n') return app.Console.PrintMessage('Saving ' + fileName + '\n') densTot = self.massTot/self.volTot if self.volTot != 0 else 0. head = ['Number', 'Label', 'Material', f'Volume ({self.unitForV})', f'Density ({self.unitForD})', f'Mass ({self.unitForM})', *(f'Center of mass {axis} ({self.unitForL})' for axis in ['X','Y','Z'])] foot = ['Total', '', '', f'{self.volTot:.6e}', f'{densTot:.6e}', f'{self.massTot:.6e}', *(f'{self.convert_length(self.TotalCoM[axis]):.6e}' for axis in range(3))] try: f = open(fileName, 'w', encoding=encoding) f.write(delimiter.join(head) + '\n') for selfsol in self.solids: sol = selfsol.sol row = [f'{sol + 1}', g_sel[sol].Label, selfsol.combo.currentText(), f'{self.convert_volume(self.volumes[sol]):.6e}', f'{selfsol.spinDens.value():.6e}', f'{self.masses[sol]:.6e}', *(f'{self.convert_length(self.CoMs[sol][axis]):.6e}' for axis in range(3))] f.write(delimiter.join(row) + '\n') f.write(delimiter.join(foot)) f.close() app.Console.PrintMessage(fileName + ' saved \n') except: error_message('Error writing file ' + fileName) def on_pushButton_Import(self): """Load a previously exported or an external bill of materials (BOM)""" encoding, delimiter = 'utf-8', VALUE_DELIMITER dstr = 'Tab' if VALUE_DELIMITER == '\t' else 'User-delimiter' fileFilter = (f'{dstr}-separated CSV or Text (*.csv *.txt)', 'Semicolon-CSV from Excel (*.csv)') fileName, selectedFilter = QtGui.QFileDialog.getOpenFileName(self, 'Open file', os.path.expanduser('~'), ';;'.join(fileFilter)) if fileName == '': return if selectedFilter == fileFilter[-1]: import locale encoding = locale.getpreferredencoding() delimiter = ';' with open(fileName, 'r', encoding=encoding) as csvfile: app.Console.PrintMessage(f'Reading from {fileName}\n') reader = csv.DictReader(csvfile, delimiter=delimiter, skipinitialspace=True) col = {} # find first column titled label(s), densit(y/ies), mass(es), material(s) respectively for name in ['label', 'densit', 'mass', 'material']: col[name] = next((s for s in reader.fieldnames if name in s.lower()), None) if col['mass'] and 'cent' in col['mass'].lower(): # the column was titled something like "Center of mass" which is not mass col['mass'] = None col_values_w_o_label = list(col.values())[1:] reader_list = list(reader) # make whole file accessible if not col['label']: error_message('Unable to find a "Label" column in the file.') return if not any(col_values_w_o_label): error_message('Unable to find a "Density", "Mass" or "Material" column in the file.') return if sum(bool(x) for x in col_values_w_o_label) > 1: app.Console.PrintWarning(' Material card takes precedence over mass over density\n') # Extract unit between round brackets try: unitForD = col['densit'].split('(')[1].split(')')[0] except: unitForD = self.unitForD try: unitForM = col['mass'].split('(')[1].split(')')[0] except: unitForM = self.unitForM self.compute_centerOfMass(False) loaded = [False] * self.solid_count # bool list whether solids have been updated reader_labels = [row[col['label']] for row in reader_list] for sol, selfsol in enumerate(self.solids): selLabel = g_sel[sol].Label if selLabel in reader_labels: row = reader_list[reader_labels.index(selLabel)] elif selLabel[-3:].isnumeric() and selLabel[:-3] in reader_labels: # admit FreeCAD's sequential numbering (001 etc.) for same objects row = reader_list[reader_labels.index(selLabel[:-3])] else: continue if col['material'] and selfsol.combo.findText(row[col['material']]) > -1: selfsol.combo.setCurrentText(row[col['material']]) loaded[sol] = True elif col['mass'] and row[col['mass']]: qty = row[col['mass']] + ' ' + unitForM qty_qty = Units.Quantity(qty).getValueAs(self.unitForM) selfsol.spinMass.setText(str(qty_qty)) selfsol.on_spinMass_edited() # trigger update loaded[sol] = True elif col['densit'] and row[col['densit']]: qty = row[col['densit']] + ' ' + unitForD selfsol.spinDens.setValue(Units.Quantity(qty).getValueAs(self.unitForD)) loaded[sol] = True self.compute_centerOfMass(True) msg = str(sum(loaded)) + ' solids loaded' if 0 < sum(loaded) <= self.solid_count/2: msg += ': ' msg += str([g_sel[sol].Label for sol in range(self.solid_count) if loaded[sol]]) if self.solid_count/2 < sum(loaded) < self.solid_count: msg += '.\nNot loaded: ' msg += str([g_sel[sol].Label for sol in range(self.solid_count) if not loaded[sol]]) app.Console.PrintMessage(msg + '\n') def valid_density_string(string): valid = False try: if Units.Quantity(string).Unit.Type == 'Density': valid = True except: pass return valid def error_message(msg): app.Console.PrintError(msg + '\n') QtGui.QMessageBox.critical(g_main_window, 'Error', msg) if __name__ == '__main__': if not valid_density_string(DEFAULT_DENSITY): app.ParamGet(MACRO_SETTINGS).RemString('Default density') error_message('Default density user parameter was set wrong. Try again.') elif not app.activeDocument(): error_message('Open a document first.') else: print('Loading ' + __Name__ + ' ' + __Version__ + ' ...') gui.updateGui() if DOCKED_WINDOW: myWidget = CenterofmassDock() else: myWidget = CenterofmassWindow()