Skip to content

c2f4dt.plugins.units.plugin

c2f4dt.plugins.units.plugin

FORCE = {'N': 1.0, 'kN': 1000.0, 'MN': 1000000.0, 'lbf': 4.4482216153} module-attribute

LEN = {'m': 1.0, 'cm': 0.01, 'mm': 0.001, 'µm': 1e-06, 'in': 0.0254, 'ft': 0.3048} module-attribute

MASS = {'kg': 1.0, 'g': 0.001, 't': 1000.0, 'lb': 0.45359237} module-attribute

PRESSURE = {'Pa': 1.0, 'kPa': 1000.0, 'MPa': 1000000.0, 'GPa': 1000000000.0, 'bar': 100000.0, 'psi': 6894.757293168} module-attribute

TEMP = {'°C': ('C',), 'K': ('K',), '°F': ('F',)} module-attribute

TIME = {'s': 1.0, 'ms': 0.001, 'min': 60.0, 'h': 3600.0} module-attribute

UnitsDialog

Bases: QDialog

Dialog to select units and perform quick conversions.

Source code in src/c2f4dt/plugins/units/plugin.py
class UnitsDialog(QtWidgets.QDialog):
    """Dialog to select units and perform quick conversions."""
    sigUnitsChanged = QtCore.Signal(object)  # emits UnitsState

    def __init__(self, parent=None, initial: Optional[UnitsState] = None):
        super().__init__(parent)
        self.setWindowTitle("Units & Scale")
        self._state = initial or UnitsState()
        self._build_ui()
        self._apply_state(self._state)

    def _build_ui(self) -> None:
        form = QtWidgets.QFormLayout(self)

        # Base units combos
        self.cmbLen = QtWidgets.QComboBox(); self.cmbLen.addItems(list(LEN.keys()))
        self.cmbMass = QtWidgets.QComboBox(); self.cmbMass.addItems(list(MASS.keys()))
        self.cmbTime = QtWidgets.QComboBox(); self.cmbTime.addItems(list(TIME.keys()))
        self.cmbForce = QtWidgets.QComboBox(); self.cmbForce.addItems(list(FORCE.keys()))
        self.cmbTemp = QtWidgets.QComboBox(); self.cmbTemp.addItems(list(TEMP.keys()))

        # Derived editable (pressure), the rest informative
        self.cmbPressure = QtWidgets.QComboBox(); self.cmbPressure.addItems(list(PRESSURE.keys()))
        self.lblDensity = QtWidgets.QLabel("kg/m³")
        self.lblEnergy = QtWidgets.QLabel("J")

        form.addRow("Length", self.cmbLen)
        form.addRow("Mass", self.cmbMass)
        form.addRow("Time", self.cmbTime)
        form.addRow("Force", self.cmbForce)
        form.addRow("Temperature", self.cmbTemp)
        form.addRow(QtWidgets.QLabel("<b>Derived</b>"))
        form.addRow("Pressure", self.cmbPressure)
        form.addRow("Density", self.lblDensity)
        form.addRow("Energy", self.lblEnergy)
        # Overlay text size control
        self.spinOverlayFont = QtWidgets.QSpinBox()
        self.spinOverlayFont.setRange(8, 36)
        self.spinOverlayFont.setValue(10)
        form.addRow("Overlay font", self.spinOverlayFont)

        # Quick converter
        box = QtWidgets.QGroupBox("Quick convert")
        h = QtWidgets.QHBoxLayout(box)
        self.spinValue = QtWidgets.QDoubleSpinBox(); self.spinValue.setRange(-1e12, 1e12); self.spinValue.setDecimals(6); self.spinValue.setValue(1.0)
        self.cmbFrom = QtWidgets.QComboBox(); self.cmbTo = QtWidgets.QComboBox()
        self.lblResult = QtWidgets.QLineEdit(); self.lblResult.setReadOnly(True)
        # default to length
        self.cmbFrom.addItems(list(LEN.keys()))
        self.cmbTo.addItems(list(LEN.keys()))
        h.addWidget(self.spinValue); h.addWidget(self.cmbFrom); h.addWidget(QtWidgets.QLabel("→")); h.addWidget(self.cmbTo); h.addWidget(self.lblResult, 1)
        form.addRow(box)

        # Buttons
        btns = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
        form.addRow(btns)

        # Signals
        for cmb in (self.cmbLen, self.cmbMass, self.cmbTime, self.cmbForce, self.cmbTemp, self.cmbPressure):
            cmb.currentTextChanged.connect(self._on_units_changed)
        for w in (self.spinValue, self.cmbFrom, self.cmbTo):
            if hasattr(w, "valueChanged"):
                w.valueChanged.connect(self._on_convert)
            else:
                w.currentTextChanged.connect(self._on_convert)
        btns.accepted.connect(self.accept)
        btns.rejected.connect(self.reject)

    def _apply_state(self, st: UnitsState) -> None:
        self.cmbLen.setCurrentText(st.length)
        self.cmbMass.setCurrentText(st.mass)
        self.cmbTime.setCurrentText(st.time)
        self.cmbForce.setCurrentText(st.force)
        self.cmbTemp.setCurrentText(st.temperature)
        st.suggest_derived()
        self.cmbPressure.setCurrentText(st.pressure)
        self.lblDensity.setText("kg/m³")  # informative only
        self.lblEnergy.setText("J")
        # apply overlay font size
        try:
            self.spinOverlayFont.setValue(int(getattr(st, "overlay_font_size", 10)))
        except Exception:
            self.spinOverlayFont.setValue(10)
        self._on_convert()

    def _on_units_changed(self) -> None:
        st = UnitsState(
            length=self.cmbLen.currentText(),
            mass=self.cmbMass.currentText(),
            time=self.cmbTime.currentText(),
            force=self.cmbForce.currentText(),
            temperature=self.cmbTemp.currentText(),
            pressure=self.cmbPressure.currentText(),
            overlay_font_size=int(self.spinOverlayFont.value()),
        )
        st.suggest_derived()
        self._state = st
        self.sigUnitsChanged.emit(st)
        self._on_convert()

    def _on_convert(self) -> None:
        v = self.spinValue.value()
        u_from = self.cmbFrom.currentText()
        u_to = self.cmbTo.currentText()

        # try linear (length/force/pressure)
        try:
            dic = None
            for table in (LEN, FORCE, PRESSURE):
                if u_from in table and u_to in table:
                    dic = table; break
            if dic:
                out = convert_linear(v, dic, u_from, u_to)
                self.lblResult.setText(f"{out:.6g}")
                return
        except Exception:
            pass
        # temperature?
        if u_from in TEMP and u_to in TEMP:
            try:
                out = convert_temperature(v, u_from, u_to)
                self.lblResult.setText(f"{out:.6g}")
                return
            except Exception:
                pass
        self.lblResult.setText("—")

    def state(self) -> UnitsState:
        return self._state

sigUnitsChanged = QtCore.Signal(object) class-attribute instance-attribute

state()

Source code in src/c2f4dt/plugins/units/plugin.py
def state(self) -> UnitsState:
    return self._state

UnitsOverlay

Manages a fixed, screen-anchored units text overlay (bottom-right).

Source code in src/c2f4dt/plugins/units/plugin.py
class UnitsOverlay:
    """Manages a fixed, screen-anchored units text overlay (bottom-right)."""
    def __init__(self, viewer3d):
        self.viewer = viewer3d
        self._text_id = None  # handle/name used by the plotter/host

    def show_text(self, units: UnitsState) -> None:
        """Show/update the units text in the bottom-right corner.

        Respects the font size stored in the UnitsState (overlay_font_size).
        """
        txt = (
            f"Units: L={units.length}, F={units.force}, "
            f"p={units.pressure}, T={units.temperature}"
        )
        font_sz = int(getattr(units, "overlay_font_size", 10))
        try:
            # Prefer a dedicated overlay API if the host provides it
            method = getattr(self.viewer, "add_overlay_text", None)
            if callable(method):
                if self._text_id is None:
                    self._text_id = method("units_overlay", txt, pos="lower_right", font_size=font_sz)
                else:
                    # If host supports updating with font size, pass it; otherwise just update text
                    update = getattr(self.viewer, "update_overlay_text", None)
                    if callable(update):
                        try:
                            update("units_overlay", txt, font_size=font_sz)
                        except TypeError:
                            update("units_overlay", txt)
            else:
                # Fallback: plotter.add_text at lower_right with given font size
                if self._text_id is None:
                    self._text_id = self.viewer.plotter.add_text(
                        txt, position="lower_right", font_size=font_sz, name="units_overlay_text"
                    )
                else:
                    try:
                        self.viewer.plotter.remove_actor("units_overlay_text")
                    except Exception:
                        pass
                    self._text_id = self.viewer.plotter.add_text(
                        txt, position="lower_right", font_size=font_sz, name="units_overlay_text"
                    )
            self.viewer.refresh()
        except Exception:
            pass

viewer = viewer3d instance-attribute

show_text(units)

Show/update the units text in the bottom-right corner.

Respects the font size stored in the UnitsState (overlay_font_size).

Source code in src/c2f4dt/plugins/units/plugin.py
def show_text(self, units: UnitsState) -> None:
    """Show/update the units text in the bottom-right corner.

    Respects the font size stored in the UnitsState (overlay_font_size).
    """
    txt = (
        f"Units: L={units.length}, F={units.force}, "
        f"p={units.pressure}, T={units.temperature}"
    )
    font_sz = int(getattr(units, "overlay_font_size", 10))
    try:
        # Prefer a dedicated overlay API if the host provides it
        method = getattr(self.viewer, "add_overlay_text", None)
        if callable(method):
            if self._text_id is None:
                self._text_id = method("units_overlay", txt, pos="lower_right", font_size=font_sz)
            else:
                # If host supports updating with font size, pass it; otherwise just update text
                update = getattr(self.viewer, "update_overlay_text", None)
                if callable(update):
                    try:
                        update("units_overlay", txt, font_size=font_sz)
                    except TypeError:
                        update("units_overlay", txt)
        else:
            # Fallback: plotter.add_text at lower_right with given font size
            if self._text_id is None:
                self._text_id = self.viewer.plotter.add_text(
                    txt, position="lower_right", font_size=font_sz, name="units_overlay_text"
                )
            else:
                try:
                    self.viewer.plotter.remove_actor("units_overlay_text")
                except Exception:
                    pass
                self._text_id = self.viewer.plotter.add_text(
                    txt, position="lower_right", font_size=font_sz, name="units_overlay_text"
                )
        self.viewer.refresh()
    except Exception:
        pass

UnitsPlugin

Bases: QObject

Wires menu entry, dialog, and overlay into the host MainWindow.

Source code in src/c2f4dt/plugins/units/plugin.py
class UnitsPlugin(QtCore.QObject):
    """Wires menu entry, dialog, and overlay into the host MainWindow."""
    def __init__(self, window):
        super().__init__(window)
        self.window = window
        self.state = UnitsState()
        self.overlay = UnitsOverlay(window.viewer3d)
        self.overlay.show_text(self.state)

        # QAction in Tools menu (or Plugins)
        self.action = QtGui.QAction(QtGui.QIcon(), "Units & Scale…", self)
        # ⬇️ evita che Qt passi l'argomento 'checked' al metodo
        self.action.triggered.connect(lambda _checked=False: self.open_dialog())

        # try to add under Tools
        try:
            mb = window.menuBar()
            m_tools = None
            for a in mb.actions():
                if a.text().replace("&", "") == "Tools":
                    m_tools = a.menu(); break
            if m_tools is None:
                m_tools = mb.addMenu("&Tools")
            m_tools.addAction(self.action)
        except Exception:
            pass

    # Accetta anche il bool opzionale
    @QtCore.Slot(bool)
    def open_dialog(self, checked: bool = False):
        dlg = UnitsDialog(self.window, initial=self.state)
        dlg.sigUnitsChanged.connect(self._on_units_changed_live)  # live preview
        if dlg.exec() == QtWidgets.QDialog.Accepted:
            self.state = dlg.state()
            # (eventuale persistenza)
        # refresh finale in entrambi i casi
        self.overlay.show_text(self.state)

    def _on_units_changed_live(self, st: UnitsState):
        self.state = st
        self.overlay.show_text(st)

    # ⬇️ così il PluginManager può “eseguire” il plugin
    def run(self, *args, **kwargs):
        return self.open_dialog(False)

action = QtGui.QAction(QtGui.QIcon(), 'Units & Scale…', self) instance-attribute

overlay = UnitsOverlay(window.viewer3d) instance-attribute

state = UnitsState() instance-attribute

window = window instance-attribute

open_dialog(checked=False)

Source code in src/c2f4dt/plugins/units/plugin.py
@QtCore.Slot(bool)
def open_dialog(self, checked: bool = False):
    dlg = UnitsDialog(self.window, initial=self.state)
    dlg.sigUnitsChanged.connect(self._on_units_changed_live)  # live preview
    if dlg.exec() == QtWidgets.QDialog.Accepted:
        self.state = dlg.state()
        # (eventuale persistenza)
    # refresh finale in entrambi i casi
    self.overlay.show_text(self.state)

run(*args, **kwargs)

Source code in src/c2f4dt/plugins/units/plugin.py
def run(self, *args, **kwargs):
    return self.open_dialog(False)

UnitsState dataclass

Source code in src/c2f4dt/plugins/units/plugin.py
@dataclass
class UnitsState:
    length: str = "m"
    mass: str = "kg"
    time: str = "s"
    force: str = "N"
    temperature: str = "°C"
    pressure: str = "Pa"   # derived, user-overridable
    density: str = "kg/m³" # derived, informative
    energy: str = "J"      # derived, informative
    overlay_font_size: int = 10  # overlay text size (points)

    def suggest_derived(self) -> None:
        """Set sensible defaults for derived quantities based on base units."""
        # pressure suggestion from force/length
        # keep user's explicit choice if already set to something consistent
        default_pressure = "Pa"
        if self.force in ("kN", "MN") and self.length in ("m",):
            default_pressure = "MPa"  # common in mech
        elif self.force in ("N",) and self.length in ("m",):
            default_pressure = "Pa"
        elif self.length in ("mm", "cm"):
            default_pressure = "MPa"  # still convenient
        self.pressure = self.pressure or default_pressure

density = 'kg/m³' class-attribute instance-attribute

energy = 'J' class-attribute instance-attribute

force = 'N' class-attribute instance-attribute

length = 'm' class-attribute instance-attribute

mass = 'kg' class-attribute instance-attribute

overlay_font_size = 10 class-attribute instance-attribute

pressure = 'Pa' class-attribute instance-attribute

temperature = '°C' class-attribute instance-attribute

time = 's' class-attribute instance-attribute

suggest_derived()

Set sensible defaults for derived quantities based on base units.

Source code in src/c2f4dt/plugins/units/plugin.py
def suggest_derived(self) -> None:
    """Set sensible defaults for derived quantities based on base units."""
    # pressure suggestion from force/length
    # keep user's explicit choice if already set to something consistent
    default_pressure = "Pa"
    if self.force in ("kN", "MN") and self.length in ("m",):
        default_pressure = "MPa"  # common in mech
    elif self.force in ("N",) and self.length in ("m",):
        default_pressure = "Pa"
    elif self.length in ("mm", "cm"):
        default_pressure = "MPa"  # still convenient
    self.pressure = self.pressure or default_pressure

convert_linear(value, u_from, from_unit, to_unit)

Generic linear factor conversion (e.g., length, force, pressure).

Source code in src/c2f4dt/plugins/units/plugin.py
def convert_linear(value: float, u_from: Dict[str, float], from_unit: str, to_unit: str) -> float:
    """Generic linear factor conversion (e.g., length, force, pressure)."""
    f = u_from[from_unit]
    t = u_from[to_unit]
    return value * (f / t)

convert_temperature(value, from_unit, to_unit)

Handle °C/°F/K non-linear conversions.

Source code in src/c2f4dt/plugins/units/plugin.py
def convert_temperature(value: float, from_unit: str, to_unit: str) -> float:
    """Handle °C/°F/K non-linear conversions."""
    if from_unit == to_unit:
        return float(value)
    # to Kelvin
    if from_unit == "K":
        k = float(value)
    elif from_unit == "°C":
        k = float(value) + 273.15
    elif from_unit == "°F":
        k = (float(value) - 32.0) * 5.0/9.0 + 273.15
    else:
        raise ValueError("Unknown temperature unit")
    # from Kelvin
    if to_unit == "K":
        return k
    if to_unit == "°C":
        return k - 273.15
    if to_unit == "°F":
        return (k - 273.15) * 9.0/5.0 + 32.0
    raise ValueError("Unknown temperature unit")

load_plugin(parent)

Source code in src/c2f4dt/plugins/units/plugin.py
def load_plugin(parent):
    return UnitsPlugin(parent)

register(parent)

Source code in src/c2f4dt/plugins/units/plugin.py
def register(parent):
    return UnitsPlugin(parent)