#!/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()