#!/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 - 2024: SyProLei project (Saarland University) # 2025: farahats9, s-quirin (former SyProLei project) # __Name__ = 'CenterOfMass' __Comment__ = 'Compute and show the center of mass for multiple solids' __Author__ = 'chupins, s-quirin, farahats9' __Version__ = '0.8.3' __Date__ = '2025-01-21' __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.20' __Communication__ = 'https://forum.freecad.org/viewtopic.php?f=24&t=31883' __Files__ = '' # Todo: # - error with draft array of meshes (relevant?) # Ideas: # - moments of inertia with arrows that allow the user to see the relative magnitudes import copy import csv import math import os import FreeCAD as app import FreeCADGui as gui import numpy as np from FreeCAD import Units from PySide import QtCore, QtGui # FreeCAD's PySide! # 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) GUI_THEME = app.ParamGet('User parameter:BaseApp/Preferences/MainWindow').GetString('Theme', '') 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()) FREECAD_VERSION = float(f'{app.Version()[0]}.{app.Version()[1]}') if FREECAD_VERSION < 0.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 if hasattr(solid.ViewObject, 'ShapeColor'): color = solid.ViewObject.ShapeColor elif hasattr(solid.ViewObject, 'ShapeAppearance'): # App::Link in FreeCAD 0.22 color = solid.ViewObject.ShapeAppearance[0].DiffuseColor 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) material = self.get_object_material(solid) if material: for i in range(self.combo.count()): if material.Name == self.combo.itemText(i): self.combo.setCurrentIndex(i) break 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() @staticmethod def get_object_material(solid): """Retrieves material properties, handling PartDesign features correctly.""" material = None if FREECAD_VERSION < 0.22: return material if hasattr(solid, "ShapeMaterial"): material = solid.ShapeMaterial elif hasattr(solid, "getParentGroup") and solid.getParentGroup(): for feature in solid.getParentGroup().Objects: if hasattr(feature, "ShapeMaterial") and feature.ShapeMaterial: material = feature.ShapeMaterial break return material 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.compute_enabled = None 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) allToDefaultDensity = QtGui.QPushButton(QtGui.QIcon(':/icons/Std_Revert.svg'), ' 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) margin = self.solidLayout.contentsMargins() self.solidLayout.setContentsMargins(0, 0, margin.right(), margin.bottom()/2) self.scroll = QtGui.QScrollArea() self.scroll.setFrameShape(QtGui.QFrame.NoFrame) # remove frame shadow in old themes 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.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.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.clicked.connect(self.on_pushButton_Save) export = QtGui.QPushButton('Export') export.setToolTip('Export values to a .csv file') export.setIcon(QtGui.QIcon(':/icons/Std_Export.svg')) 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.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'): if self.doc.getObject('CenterOfMass').TypeId == 'App::DocumentObjectGroup': 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(showCoM.isChecked()) 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_Coloring) 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) 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) self.com_clipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'), '') self.com_clipboard.setToolTip(f'Copy to clipboard ({self.unitForL})') self.com_clipboard.setFlat(True) self.com_clipboard.clicked.connect(self.on_pushButton_copyToClipboardCdG) axis_labels = [{'label': 'X', 'style': 'color:rgb(255, 0, 0)'}, {'label': 'Y', 'style': 'color:rgb(0, 255, 0)'}, {'label': 'Z', 'style': 'color:rgb(0, 0, 255)'}] cdgLayout = QtGui.QHBoxLayout() for index, axis in enumerate(axis_labels): label_CdG = QtGui.QLabel(axis['label']) label_CdG.setStyleSheet(axis['style']) cdgLayout.addWidget(label_CdG) cdgLayout.addWidget(self.resultCdG[index]) cdgLayout.addWidget(self.com_clipboard) cdgGroupBox = QtGui.QGroupBox('Center of Mass') 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) self.total_clipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'), '') self.total_clipboard.setToolTip(f'Copy to clipboard ({self.unitForM} and {self.unitForD})') self.total_clipboard.setFlat(True) self.total_clipboard.clicked.connect(self.on_pushButton_copyToClipboardTotal) totalLayout = QtGui.QGridLayout() totalLayout.addWidget(label_Mass, 0, 0) totalLayout.addWidget(self.resultMass, 0, 1) totalLayout.addWidget(label_Density, 0, 2) totalLayout.addWidget(self.resultDensity, 0, 3) totalLayout.addWidget(self.total_clipboard, 0, 4) self.volume_clipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'), '') self.volume_clipboard.setToolTip(f'Copy to clipboard ({self.unitForV} and {self.unitForA})') self.volume_clipboard.setFlat(True) self.volume_clipboard.clicked.connect(self.on_pushButton_copyToClipboardVolume) label_Volume = QtGui.QLabel('Volume') self.resultVolume = QtGui.QLineEdit(self) self.resultVolume.setObjectName('total volume') self.resultVolume.setReadOnly(True) label_Area = QtGui.QLabel('Area') self.resultArea = QtGui.QLineEdit(self) self.resultArea.setReadOnly(True) self.resultArea.setObjectName('total surface area') totalLayout.addWidget(label_Volume, 1, 0) totalLayout.addWidget(self.resultVolume, 1, 1) totalLayout.addWidget(label_Area, 1, 2) totalLayout.addWidget(self.resultArea, 1, 3) totalLayout.addWidget(self.volume_clipboard, 1, 4) totalGroupBox = QtGui.QGroupBox('Total') totalGroupBox.setLayout(totalLayout) layout.addWidget(totalGroupBox) self.resultMoI = [] inertiaLayout = QtGui.QGridLayout() inertiaLayout.setVerticalSpacing(0) # reduce vertical window size for i, axis in enumerate(axis_labels): label_row = QtGui.QLabel(axis['label']) label_row.setStyleSheet(axis['style']) inertiaLayout.addWidget(label_row, 0, i+1) label_row.setAlignment(QtCore.Qt.AlignCenter) for j, axis in enumerate(axis_labels): label_col = QtGui.QLabel(axis['label']) label_col.setStyleSheet(axis['style']) inertiaLayout.addWidget(label_col, j+1, 0) label_col.setAlignment(QtCore.Qt.AlignCenter) for i in range(3): self.resultMoI.append([]) for j in range(3): line_edit = QtGui.QLineEdit(self) self.resultMoI[i].append(line_edit) line_edit.setAlignment(QtCore.Qt.AlignCenter) inertiaLayout.addWidget(line_edit, j+1, i+1) inertiaGroupBox = QtGui.QGroupBox('Moments of Inertia') inertiaGroupBox.setToolTip('Taken at the center of mass and aligned with global coordinates') inertiaGroupBox.setLayout(inertiaLayout) layout.addWidget(inertiaGroupBox) layout.addStretch() # minimize all groupboxes to their necessary vertical size self.inertia_clipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'), '') self.inertia_clipboard.setToolTip(f'Copy to clipboard ({self.unitForI})') self.inertia_clipboard.setFlat(True) self.inertia_clipboard.clicked.connect(self.on_pushButton_copyToClipboardInertia) inertiaLayout.addWidget(self.inertia_clipboard, 2, 4) 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) if FREECAD_VERSION > 0.22 and GUI_THEME in ['FreeCAD Light', 'FreeCAD Dark']: # BUG if theme non Classic label.setStyleSheet('') 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 FREECAD_VERSION > 0.22 and GUI_THEME in ['FreeCAD Light', 'FreeCAD Dark']: # BUG if theme non Classic selfsol.label.setStyleSheet('QFrame {border: 1px solid #B477FF}') 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): macroWidgetFocus = False if w is not None: try: macroWidgetFocus = w.parent() == self.solidLayout.parentWidget() except RuntimeError: pass # deleted (None) objects can be passed if macroWidgetFocus: 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', 'Assembly::AssemblyObject') 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: p_ = s_.getParent() if callable(getattr(s_, 'getParent', None)) else False # no getParent() in FreeCAD 0.20 if p_ and p_.Name == 'CenterOfMass' and p_.TypeId == 'App::DocumentObjectGroup': # Skip objects created for the macro to avoid error or crash continue elif 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) if tree.topLevelItem(0) is None: # FreeCAD > 0.22 for t_ in g_main_window.findChildren(QtGui.QTreeWidget): if t_.topLevelItem(0) is not None: tree = t_ iterator = QtGui.QTreeWidgetItemIterator(tree, QtGui.QTreeWidgetItemIterator.Editable) self.tree_list = [i_.value().text(0) for i_ in 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 if 0.22 <= FREECAD_VERSION < 1.0: import UtilsAssembly 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() # e.g. App::Link has no method getGlobalPlacement elif FREECAD_VERSION < 0.22: if s_.InList: for it in s_.InListRecursive: o_.Placement = o_.Placement.multiply(it.Placement) elif 0.22 <= FREECAD_VERSION < 1.0: o_.Placement = UtilsAssembly.getGlobalPlacement(s_) elif s_.TypeId != 'Assembly::JointGroup' or s_.TypeId == 'App::Link': if s_.InList: for it in s_.InListRecursive: if it.TypeId != 'Assembly::JointGroup': o_.Placement = o_.Placement.multiply(it.Placement) elif s_.TypeId == 'Assembly::JointGroup': continue 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 preferences""" from pathlib import Path import importFCMat resources: list[Path] = [] if USE_BUILT_IN_MATERIALS: if FREECAD_VERSION < 0.22: resources.append(Path(app.getResourceDir(), "Mod", "Material", "StandardMaterial")) else: resources.append(Path( app.getResourceDir(), "Mod", "Material", "Resources", "Materials", "Standard") ) if USE_MAT_FROM_CONFIG_DIR: resources.append(Path(app.ConfigGet("UserAppData"), "Material")) if USE_MAT_FROM_CUSTOM_DIR and CUSTOM_MAT_DIR: resources.append(Path(CUSTOM_MAT_DIR)) app.Console.PrintMessage('Looking for material cards according to ' + 'User parameter:BaseApp/Preferences/Mod/Material/Resources' + '\n') # Read material cards for p in resources: app.Console.PrintMessage(f' {p}' + '\n') # cards found later with same name will override previous ones dir_gen = p.rglob('*.FCMat') # is generator -> use only once for f in dir_gen: if f.stem == 'Default': continue try: d = importFCMat.read(str(f)).get('Density') except LookupError: pass else: if d and Units.Quantity(d).Value > 0: self.material_base[f.stem] = Units.Quantity(d) 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 self.unitForA = self.unitForV.replace('^3', '^2') # area self.unitForI = self.unitForM + '*' + self.unitForA # inertia 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 convert_area(self, area): """Convert area from internal FreeCAD to preference unit""" pq = Units.parseQuantity return float(pq(f'{area} mm^2') / pq(self.unitForA)) def convert_inertia(self, inertia): """Convert length from internal FreeCAD to preference unit""" pq = Units.parseQuantity return float(pq(f'{inertia} kg*mm^2') / pq(self.unitForI)) 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.com_clipboard.setToolTip(f'Copy to clipboard ({self.unitForL})') self.total_clipboard.setToolTip(f'Copy to clipboard ({self.unitForM} and {self.unitForD})') self.volume_clipboard.setToolTip(f'Copy to clipboard ({self.unitForV} and {self.unitForA})') self.inertia_clipboard.setToolTip(f'Copy to clipboard ({self.unitForI})') 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.areas = [0] * self.solid_count self.masses = [0] * self.solid_count self.CoMs = [app.Vector(0, 0, 0)] * self.solid_count self.MoIs = [np.zeros((3, 3))] * 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 self.areas[sol] = objs[sol].Area 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 get_MoI(self, matrix, density): # Body's inertia matrix in mm^5 inertia_body = np.array(matrix.A).reshape(4, 4)[:3, :3] # Inertia matrix in mm⁵ qty = str(density) + ' ' + self.unitForD density_kg_mm3 = Units.Quantity(qty).getValueAs('kg/mm^3').Value inertia_body_kg_mm2 = inertia_body * density_kg_mm3 # Convert to kg·mm² return inertia_body_kg_mm2 def compute_momentOfInertia(self, icm, mass, CoM): com_body_mm = np.array(CoM) # COM in mm com_assembly_mm = np.array(self.TotalCoM) # Assembly COM in mm # Distance vector from assembly COM to body's COM r_mm = com_body_mm - com_assembly_mm # mm # Apply the Parallel Axis Theorem parallel_axis_term_kg_mm2 = mass * (np.dot(r_mm, r_mm) * np.eye(3) - np.outer(r_mm, r_mm)) I = icm + parallel_axis_term_kg_mm2 return I 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.areaTot = 0. self.TotalCoM = app.Vector(0, 0, 0) self.TotalMoI = np.zeros((3, 3)) for sol in range(self.solid_count): if not isinstance(self.solids[sol], SolidsWidget): return volumeInUnit = self.convert_volume(self.volumes[sol]) areaInUnit = self.convert_area(self.areas[sol]) self.masses[sol] = volumeInUnit * self.solids[sol].spinDens.value() if self.masses[sol] == 0: continue self.massTot += self.masses[sol] self.volTot += volumeInUnit self.areaTot += areaInUnit 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] if hasattr(g_sel[sol], 'MatrixOfInertia'): self.MoIs[sol] = self.get_MoI(g_sel[sol].MatrixOfInertia, self.solids[sol].spinDens.value()) elif hasattr(g_sel[sol],'Shape') and hasattr(g_sel[sol].Shape, 'MatrixOfInertia'): self.MoIs[sol] = self.get_MoI(g_sel[sol].Shape.MatrixOfInertia, self.solids[sol].spinDens.value()) else: app.Console.PrintWarning(f'Solid {self.solids[sol].label.text()} has no "MatrixOfInertia" \n') for sol in range(self.solid_count): self.TotalMoI += self.compute_momentOfInertia(self.MoIs[sol], self.masses[sol], self.CoMs[sol]) # output for sol, selfsol in enumerate(self.solids): selfsol.spinMass.setText(f'{self.masses[sol]:.4f}') 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.resultVolume.setText(f'{self.volTot:.6f}') self.resultVolume.setToolTip(f'total volume (in {self.unitForV})') self.resultArea.setText(f'{self.areaTot:.6f}') self.resultArea.setToolTip(f'total surface area (in {self.unitForA})') self.resultMass.setText(f'{self.massTot:.6f}') 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})') axis = ['x', 'y', 'z'] for i in range(3): for j in range(3): self.resultMoI[i][j].setText(f'{self.convert_inertia(self.TotalMoI[i, j]):.6}') self.resultMoI[i][j].setToolTip(f'L{axis[i]}{axis[j]} (in {self.unitForI})') if self.changeRadius.isEnabled(): self.draw_centerOfMass() if self.checkColorify.isChecked(): self.coloring() 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 CoMObjs = self.doc.getObject('CenterOfMass') # none if no object if CoMObjs: CoMObjs.removeObjectsFromDocument() # remove childs else: CoMObjs = self.doc.addObject('App::DocumentObjectGroup', 'CenterOfMass') # These objects will be created by the macro and should not exist anymore in the document for s_ in ('CoMBBoxOfSel', 'CoMLCS', 'CoMTotal', 'CoMPlaneYZ', 'CoMPlaneXZ', 'CoMPlaneXY'): if self.doc.getObject(s_): self.doc.removeObject(s_) # 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() 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): CoMObjs = self.doc.getObject('CenterOfMass') # none if no object if state == QtCore.Qt.Checked: if CoMObjs and CoMObjs.TypeId != 'App::DocumentObjectGroup': error_message(f'Please delete the object that occupies the name "CenterOfMass".') return self.draw_centerOfMass() self.changeRadius.setEnabled(True) else: self.changeRadius.setEnabled(False) try: self.set_objects_transparent(False) CoMObjs.removeObjectsFromDocument() self.doc.removeObject('CenterOfMass') except: pass def on_slideButton_changeRadius(self): self.draw_update_sphere_radius() def on_stateChanged_Coloring(self, state): if state == QtCore.Qt.Checked: self.coloring() else: for sol, selfsol in enumerate(self.solids): self.set_object_color(g_sel[sol].Name, selfsol.orgColorFC) if COLOR_SPHERES and self.changeRadius.isEnabled(): self.set_object_color('CoM_' + g_sel[sol].Name, selfsol.orgColorFC) selfsol.spinDens.setPalette(QtGui.QPalette()) # reset to normal if FREECAD_VERSION > 0.22 and GUI_THEME in ['FreeCAD Light', 'FreeCAD Dark']: # BUG if theme non Classic selfsol.spinDens.setStyleSheet('') def on_comboColormap_changed(self, newText): if self.checkColorify.isChecked(): self.coloring() def set_object_color(self, name, color): obj = self.doc.getObject(name) if hasattr(obj.ViewObject, 'ShapeColor'): obj.ViewObject.ShapeColor = color elif hasattr(obj.ViewObject, 'ShapeAppearance'): # App::Link in FreeCAD 0.22 material = obj.ViewObject.ShapeAppearance[0] material.DiffuseColor = color obj.ViewObject.ShapeAppearance = (material, ) # overwrite tuple def coloring(self): cmName = self.comboColormap.currentText() if cmName != 'Traffic': if FREECAD_VERSION < 0.22: import matplotlib.cm cm = matplotlib.cm.get_cmap(cmName) else: import matplotlib cm = matplotlib.colormaps[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): if self.masses[sol] == 0: self.set_object_color(g_sel[sol].Name, selfsol.orgColorFC) selfsol.spinDens.setPalette(QtGui.QPalette()) # reset to normal if FREECAD_VERSION > 0.22 and GUI_THEME in ['FreeCAD Light', 'FreeCAD Dark']: # BUG if theme non Classic selfsol.spinDens.setStyleSheet('') 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.set_object_color(g_sel[sol].Name, color.getRgbF()) if COLOR_SPHERES and self.changeRadius.isEnabled(): self.set_object_color('CoM_' + g_sel[sol].Name, 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) textColor = 'white' else: textColor = 'black' selfsol.spinDens.setPalette(pal) if FREECAD_VERSION > 0.22 and GUI_THEME in ['FreeCAD Light', 'FreeCAD Dark']: # BUG if theme non Classic selfsol.spinDens.setStyleSheet( 'QDoubleSpinBox {background-color: %s; color: %s}' % (color.name(), textColor)) 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.convert_length(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_copyToClipboardVolume(self): """Copy total volume and area to clipboard (in FreeCAD Standard unit system)""" string = VALUE_DELIMITER.join((str(self.volTot), str(self.areaTot))) QtGui.QGuiApplication.clipboard().setText(string) def on_pushButton_copyToClipboardInertia(self): """Copy total mass and density to clipboard (in FreeCAD Standard unit system)""" string = '' for i in range(3): string += VALUE_DELIMITER.join(str(self.convert_inertia(self.TotalMoI[i][axis])) for axis in range(3)) string += '\n' 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 FREECAD_VERSION < 0.22: 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'Area ({self.unitForA})', f'Density ({self.unitForD})', f'Mass ({self.unitForM})', *(f'Center of mass {axis} ({self.unitForL})' for axis in ['X', 'Y', 'Z']), *(f'L{axis1}{axis2} ({self.unitForI})' for axis1 in ['x', 'y', 'z'] for axis2 in ['x', 'y', 'z'])] foot = ['Total', '', '', f'{self.volTot:.6e}', f'{self.areaTot:.6e}', f'{densTot:.6e}', f'{self.massTot:.6e}', *(f'{self.convert_length(self.TotalCoM[axis]):.6e}' for axis in range(3)), *(f'{self.convert_inertia(self.TotalMoI[axis1, axis2]):.6e}' for axis1 in range(3) for axis2 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'{self.convert_area(self.areas[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'{self.convert_inertia(self.MoIs[sol][axis1, axis2]):.6e}' for axis1 in range(3) for axis2 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] # Get material if not 'custom' from combobox items (0=custom) else get mass then density items = [self.solids[0].combo.itemText(i) for i in range(1, self.solids[0].combo.count())] items.remove('') # remove empty item e.g. line separator 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 row[col['material']] in items: 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: app.Console.PrintMessage(f'Loading {__Name__} {__Version__} ...' + '\n') gui.updateGui() if DOCKED_WINDOW: myWidget = CenterofmassDock() else: myWidget = CenterofmassWindow()