diff --git a/pyproject.toml b/pyproject.toml index b243a5e..18da3f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ include = [ "src/setuav_studio/ui/editor/component.py", "src/setuav_studio/ui/editor/envelope.py", "src/setuav_studio/ui/editor/instance.py", + "src/setuav_studio/ui/editor/mass.py", "src/setuav_studio/ui/editor/transform.py", "src/setuav_studio/api/__init__.py", "src/setuav_studio/api/api.py", @@ -162,6 +163,7 @@ include = [ "src/setuav_studio/model/data.py", "src/setuav_studio/model/environment.py", "src/setuav_studio/model/expression.py", + "src/setuav_studio/model/mass.py", "src/setuav_studio/model/parameter.py", "src/setuav_studio/model/state.py", "src/setuav_studio/model/symbol.py", diff --git a/src/plugins/weight_balance/engine/solver.py b/src/plugins/weight_balance/engine/solver.py index 8a72573..2990355 100644 --- a/src/plugins/weight_balance/engine/solver.py +++ b/src/plugins/weight_balance/engine/solver.py @@ -168,7 +168,9 @@ def _component_properties( ) mass_g = root_mass if root_mass is not None else parameter_mass - requested_source = str(wb_extension.get("mass_source") or "") + requested_source = str( + component.get("mass_source") or wb_extension.get("mass_source") or "" + ) source = requested_source or ("declared" if mass_g is not None else "missing") if mass_g is None or mass_g <= 0.0: return None @@ -179,7 +181,9 @@ def _component_properties( geometry = parameters.get("geometry") geometry = geometry if isinstance(geometry, dict) else {} - symmetry_mode = str(wb_extension.get("symmetry_mode") or "pair") + symmetry_mode = str( + component.get("symmetry_mode") or wb_extension.get("symmetry_mode") or "pair" + ) if symmetry_mode == "pair" and (geometry.get("mirror") is True or mirrored_frame): # A mirrored lifting surface, and its attached control surfaces, # represent the complete left/right pair. Their aggregate CG is @@ -187,7 +191,11 @@ def _component_properties( # component has a local attachment offset on Y. cg_body = (cg_body[0], 0.0, cg_body[2]) - inertia_value = wb_extension.get("inertia_kg_m2") + inertia_value = ( + component.get("inertia_kg_m2") + or component.get("inertia") + or wb_extension.get("inertia_kg_m2") + ) if inertia_value is None: inertia_value = parameters.get("inertia") inertia, has_declared_inertia = _inertia(inertia_value) @@ -235,7 +243,9 @@ def _component_cg_value( wb_extension: dict[str, Any], envelope: dict[str, Any] | None, ) -> tuple[object, bool]: - value = wb_extension.get("local_cg_mm") + value = ( + component.get("local_cg_mm") or component.get("local_cg") or wb_extension.get("local_cg_mm") + ) declared = isinstance(value, dict) if not declared and envelope is not None: offset = envelope.get("offset_mm") diff --git a/src/plugins/weight_balance/mass_definition_dock.py b/src/plugins/weight_balance/mass_definition_dock.py index 9f662bc..1ad1634 100644 --- a/src/plugins/weight_balance/mass_definition_dock.py +++ b/src/plugins/weight_balance/mass_definition_dock.py @@ -1,387 +1,18 @@ -"""Properties editor for a component's declared mass properties.""" +"""Backward-compatibility shim for MassPropertiesEditor. -from __future__ import annotations +The editor implementation has moved to core (`setuav_studio.ui.editor.mass`). +""" -from copy import deepcopy -from typing import Any +from __future__ import annotations -from PySide6.QtCore import Qt, QTimer -from PySide6.QtWidgets import ( - QAbstractItemView, - QHBoxLayout, - QHeaderView, - QLabel, - QPushButton, - QScrollArea, - QSizePolicy, - QTableWidget, - QVBoxLayout, - QWidget, +from setuav_studio.ui.editor.mass import ( + EXTENSION_ID, + WB_EXTENSION_ID, + MassPropertiesEditor, ) -from setuav_studio.ui.icons import get_icon, set_label_icon -from setuav_studio.ui.widget.spinbox import NumericSpinBox, set_table_spinbox -from setuav_studio.ui.widget.table import PropertyTableMixin -from setuav_studio_sdk import StudioAPI - -from .engine.solver import EXTENSION_ID - - -class MassPropertiesEditor(PropertyTableMixin, QWidget): - """Table-based mass editor styled like the other Setuav property docks.""" - - table_scroll_policy_off = True - table_max_visible_rows = None - - def __init__( - self, - api: StudioAPI, - selection: dict[str, Any], - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setObjectName("weight_balance.mass_properties_editor") - self._api = api - component_id = str(selection.get("component_id") or "") - self._component = ( - api.current_project.get_component(component_id) - if api.current_project is not None and component_id - else None - ) - self._loading = False - self._pending_before: dict[str, Any] | None = None - self._commit_timer = QTimer(self) - self._commit_timer.setSingleShot(True) - self._commit_timer.timeout.connect(self._commit_pending) - self.destroyed.connect(self._commit_timer.stop) - self._section_icons: list[tuple[QLabel, str]] = [] - - root_layout = QVBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - - content = QWidget() - self._content_layout = QVBoxLayout(content) - self._content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self._content_layout.setContentsMargins(6, 6, 6, 8) - self._content_layout.setSpacing(10) - - scroll = QScrollArea(self) - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.Shape.NoFrame) - scroll.setWidget(content) - root_layout.addWidget(scroll) - - self._create_mass_section(component_id) - self._create_cg_section() - self._create_inertia_section() - self._content_layout.addStretch(1) - - # Compatibility handle for callers of the old explicit-Apply editor. - # It is intentionally not visible; edits are committed automatically. - self.apply_button = QPushButton(self) - self.apply_button.setIcon(get_icon("fa6s.check")) - self.apply_button.setVisible(False) - self.apply_button.clicked.connect(self._commit_pending) - - if self._component is not None: - self._load_component(self._component) - - def update_theme_style(self) -> None: - for label, icon_name in self._section_icons: - set_label_icon(label, icon_name) - - def _create_section(self, title: str, icon_name: str) -> QVBoxLayout: - section = QWidget() - section.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Maximum, - ) - layout = QVBoxLayout(section) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(3) - - header = QWidget(section) - header.setProperty("sectionHeader", True) - header.setFixedHeight(20) - header_layout = QHBoxLayout(header) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(5) - - icon_label = QLabel(header) - set_label_icon(icon_label, icon_name) - icon_label.setFixedSize(14, 14) - self._section_icons.append((icon_label, icon_name)) - header_layout.addWidget(icon_label) - header_layout.addWidget(QLabel(title, header)) - header_layout.addStretch(1) - - layout.addWidget(header) - self._content_layout.addWidget(section) - return layout - - def _create_mass_section(self, component_id: str) -> None: - layout = self._create_section("Mass", "fa6s.cubes-stacked") - self.mass_table = self._property_table( - [ - ("component", "Component"), - ("mass", "Mass"), - ] - ) - component_name = ( - str(self._component.get("name") or component_id) - if self._component is not None - else "Missing component" - ) - self._set_property_value( - self.mass_table, - "component", - component_name, - editable=False, - ) - self.mass_g = self._set_numeric_cell( - self.mass_table, - "mass", - minimum=0.0, - maximum=1_000_000_000.0, - step=1.0, - decimals=3, - quantity="mass", - suffix="g", - on_changed=self._on_field_changed, - ) - layout.addWidget(self.mass_table) - - def _create_cg_section(self) -> None: - layout = self._create_section("Local Center of Gravity", "fa6s.crosshairs") - self.cg_table = QTableWidget(1, 3) - self.cg_table.setHorizontalHeaderLabels(["X", "Y", "Z"]) - self.cg_table.setVerticalHeaderLabels(["Position"]) - self.cg_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.cg_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) - self.cg_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.cg_table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.cg_table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.cg_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.cg_table.horizontalHeader().setFixedHeight(23) - self.cg_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) - self.cg_table.verticalHeader().setDefaultSectionSize(23) - self.cg_table.verticalHeader().setMinimumWidth(82) - self.cg_table.setAlternatingRowColors(True) - self.cg_table.setFixedHeight(48) - - self.cg_spins = { - axis: set_table_spinbox( - self.cg_table, - 0, - column, - 0.0, - min_val=-10_000_000.0, - max_val=10_000_000.0, - step=1.0, - decimals=3, - quantity="length", - suffix="mm", - on_changed=self._on_field_changed, - ) - for column, axis in enumerate(("x", "y", "z")) - } - layout.addWidget(self.cg_table) - - def _create_inertia_section(self) -> None: - layout = self._create_section("Inertia Tensor", "fa6s.cube") - moment_keys = ("ixx", "iyy", "izz") - product_keys = ("ixy", "ixz", "iyz") - self.inertia_moments_table = self._inertia_row_table( - [key.upper() for key in moment_keys], - "Moments", - ) - self.inertia_products_table = self._inertia_row_table( - [key.upper() for key in product_keys], - "Products", - ) - self.inertia_spins = {} - for table, keys, minimum in ( - (self.inertia_moments_table, moment_keys, 0.0), - (self.inertia_products_table, product_keys, -1_000_000.0), - ): - self.inertia_spins.update( - { - key: set_table_spinbox( - table, - 0, - column, - 0.0, - min_val=minimum, - max_val=1_000_000.0, - step=0.000001, - decimals=8, - quantity="inertia", - suffix="kg·m²", - on_changed=self._on_field_changed, - ) - for column, key in enumerate(keys) - } - ) - layout.addWidget(self.inertia_moments_table) - layout.addWidget(self.inertia_products_table) - - @staticmethod - def _inertia_row_table(headers: list[str], row_label: str) -> QTableWidget: - table = QTableWidget(1, 3) - table.setHorizontalHeaderLabels(headers) - table.setVerticalHeaderLabels([row_label]) - table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) - table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - table.horizontalHeader().setFixedHeight(23) - table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) - table.verticalHeader().setDefaultSectionSize(23) - table.verticalHeader().setMinimumWidth(82) - table.setAlternatingRowColors(True) - table.setFixedHeight(48) - return table - - def _set_numeric_cell( - self, - table: QTableWidget, - key: str, - *, - minimum: float, - maximum: float, - step: float, - decimals: int, - quantity: str = "", - suffix: str, - on_changed: Any | None = None, - ) -> NumericSpinBox: - for row in range(table.rowCount()): - if self._property_key(table, row) == key: - return set_table_spinbox( - table, - row, - 1, - 0.0, - min_val=minimum, - max_val=maximum, - step=step, - decimals=decimals, - quantity=quantity, - suffix=suffix, - on_changed=on_changed, - ) - raise KeyError(f"Unknown mass-properties field: {key}") - - def _load_component(self, component: dict[str, Any]) -> None: - self._loading = True - try: - source = self._source_component(component) - parameters = component.get("parameters") - parameters = parameters if isinstance(parameters, dict) else {} - source_parameters = source.get("parameters") - source_parameters = source_parameters if isinstance(source_parameters, dict) else {} - mass = component.get( - "mass", - parameters.get( - "mass", - source.get("mass", source_parameters.get("mass", 0.0)), - ), - ) - self.mass_g.setValue(_number(mass)) - - source_extensions = source.get("extensions") - source_extensions = source_extensions if isinstance(source_extensions, dict) else {} - source_definition = source_extensions.get(EXTENSION_ID) - source_definition = source_definition if isinstance(source_definition, dict) else {} - extensions = component.get("extensions") - extensions = extensions if isinstance(extensions, dict) else {} - own_definition = extensions.get(EXTENSION_ID) - own_definition = own_definition if isinstance(own_definition, dict) else {} - definition = dict(source_definition) - definition.update(own_definition) - cg = definition.get("local_cg_mm") - if not isinstance(cg, dict): - envelope = component.get("envelope") or source.get("envelope") - if isinstance(envelope, dict): - candidate = envelope.get("offset_mm") - if isinstance(candidate, dict): - cg = candidate - cg = cg if isinstance(cg, dict) else {} - for axis, spin in self.cg_spins.items(): - spin.setValue(_number(cg.get(axis))) - - inertia = definition.get( - "inertia_kg_m2", - parameters.get("inertia", source_parameters.get("inertia")), - ) - inertia = inertia if isinstance(inertia, dict) else {} - for key, spin in self.inertia_spins.items(): - spin.setValue(_number(inertia.get(key))) - finally: - self._loading = False - - def _on_field_changed(self, _value: float) -> None: - component = self._component - if self._loading or component is None: - return - if self._pending_before is None: - self._pending_before = deepcopy(component) - mass_g = self.mass_g.value() - cg = {axis: spin.value() for axis, spin in self.cg_spins.items()} - inertia = {key: spin.value() for key, spin in self.inertia_spins.items()} - - def change() -> None: - component["mass"] = mass_g - parameters = component.get("parameters") - if isinstance(parameters, dict) and "mass" in parameters: - parameters["mass"] = mass_g - extensions = component.setdefault("extensions", {}) - if not isinstance(extensions, dict): - extensions = {} - component["extensions"] = extensions - definition = extensions.setdefault(EXTENSION_ID, {}) - if not isinstance(definition, dict): - definition = {} - extensions[EXTENSION_ID] = definition - definition.update( - { - "mass_source": "declared", - "local_cg_mm": cg, - "inertia_kg_m2": inertia, - } - ) - - change() - self._commit_timer.start(0) - - def _commit_pending(self) -> None: - self._commit_timer.stop() - component = self._component - before = self._pending_before - if component is None or before is None: - return - after = deepcopy(component) - self._pending_before = None - component.clear() - component.update(before) - self._api.edit_component( - component, - "Edit component mass definition", - lambda: component.update(deepcopy(after)), - ) - - def _source_component(self, component: dict[str, Any]) -> dict[str, Any]: - if component.get("kind") != "instance" or self._api.current_project is None: - return component - source_id = component.get("source") - source = self._api.current_project.get_component(str(source_id or "")) - return source if source is not None else component - - -def _number(value: object) -> float: - try: - return float(value or 0.0) - except (TypeError, ValueError): - return 0.0 +__all__ = [ + "EXTENSION_ID", + "WB_EXTENSION_ID", + "MassPropertiesEditor", +] diff --git a/src/plugins/weight_balance/models.py b/src/plugins/weight_balance/models.py index f98c8ec..5a6ea61 100644 --- a/src/plugins/weight_balance/models.py +++ b/src/plugins/weight_balance/models.py @@ -5,64 +5,22 @@ from dataclasses import dataclass, field from typing import Any -from setuav_studio.model import Component - -Vector3 = tuple[float, float, float] - - -@dataclass(frozen=True, slots=True) -class InertiaTensor: - """Symmetric inertia tensor expressed about a stated reference point.""" - - ixx: float = 0.0 - iyy: float = 0.0 - izz: float = 0.0 - ixy: float = 0.0 - ixz: float = 0.0 - iyz: float = 0.0 - - def as_matrix(self) -> tuple[tuple[float, float, float], ...]: - # Products of inertia use the conventional negative off-diagonal form. - return ( - (self.ixx, -self.ixy, -self.ixz), - (-self.ixy, self.iyy, -self.iyz), - (-self.ixz, -self.iyz, self.izz), - ) - - @classmethod - def from_matrix( - cls, - matrix: tuple[tuple[float, float, float], ...], - ) -> InertiaTensor: - return cls( - ixx=matrix[0][0], - iyy=matrix[1][1], - izz=matrix[2][2], - ixy=-matrix[0][1], - ixz=-matrix[0][2], - iyz=-matrix[1][2], - ) - - -@dataclass(frozen=True, slots=True) -class MassProperties: - mass_kg: float - cg_body_m: Vector3 - inertia_cg_kg_m2: InertiaTensor - - -@dataclass(frozen=True, slots=True) -class ComponentMassProperties: - component_id: str - component_name: str - mass_kg: float - cg_local_m: Vector3 - cg_body_m: Vector3 - inertia_local_kg_m2: InertiaTensor - source: str - quality: str - warnings: tuple[str, ...] = () - component_type: str = "" +from setuav_studio.model import ( + Component, + ComponentMassProperties, + InertiaTensor, + MassProperties, + Vector3, +) + +__all__ = [ + "ComponentMassProperties", + "InertiaTensor", + "MassProperties", + "PointMassModel", + "Vector3", + "WeightBalanceResult", +] @dataclass(slots=True) diff --git a/src/plugins/weight_balance/plugin.py b/src/plugins/weight_balance/plugin.py index ba500e7..1746d55 100644 --- a/src/plugins/weight_balance/plugin.py +++ b/src/plugins/weight_balance/plugin.py @@ -7,7 +7,6 @@ from PySide6.QtCore import Qt from setuav_studio_sdk import ( - ComponentTreeNodeContribution, PanelContribution, StudioAPI, StudioEvents, @@ -19,7 +18,6 @@ from .balance_view_dock import WeightBalanceViewDock from .engine.base import WeightBalanceError from .engine.solver import EXTENSION_ID, WeightBalanceSolver -from .mass_definition_dock import MassPropertiesEditor from .point_mass_editor import PointMassEditor from .results_dock import WeightBalanceResultsDock @@ -78,11 +76,6 @@ def activate(self, api: StudioAPI) -> None: "org.setuav.core:point-mass", POINT_MASS_ICON, ) - api.register_component_tree_provider(EXTENSION_ID, self._mass_property_nodes) - api.register_kind_editor( - "mass-properties", - lambda selection: MassPropertiesEditor(api, selection), - ) api.register_component_editor( "org.setuav.core:point-mass", lambda component: PointMassEditor(api, component), @@ -125,11 +118,9 @@ def deactivate(self, api: StudioAPI) -> None: api.remove_workspace("studio.workspace.weight_balance") api.remove_project_listener(self._project_changed) api.remove_project_content_listener(self._project_changed) - api.remove_kind_editor("mass-properties") api.remove_component_model("org.setuav.core:point-mass") api.remove_component_editor("org.setuav.core:point-mass") api.remove_component_icon("org.setuav.core:point-mass") - api.remove_component_tree_provider(EXTENSION_ID) self._api = None def _can_edit_project(self) -> bool: @@ -202,29 +193,6 @@ def change() -> None: api.set_selection(component) api.show_status(f"Created {component_name}", "success", 3000) - @staticmethod - def _mass_property_nodes( - component: dict, - ) -> tuple[ComponentTreeNodeContribution, ...]: - component_id = str(component.get("id") or "") - if not component_id: - return () - node_id = f"{component_id}:mass-properties" - return ( - ComponentTreeNodeContribution( - id=node_id, - title="Mass", - selection={ - "id": node_id, - "name": "Mass", - "kind": "mass-properties", - "component_id": component_id, - }, - icon="mass", - tooltip=f"Mass, local CG and inertia for {component.get('name') or component_id}", - ), - ) - def run_analysis(self) -> None: if self._api is None or self._api.current_project is None: return diff --git a/src/setuav_studio/model/__init__.py b/src/setuav_studio/model/__init__.py index 59eca12..468875b 100644 --- a/src/setuav_studio/model/__init__.py +++ b/src/setuav_studio/model/__init__.py @@ -21,6 +21,12 @@ ExpressionEvaluationError, ExpressionEvaluator, ) +from setuav_studio.model.mass import ( + ComponentMassProperties, + InertiaTensor, + MassProperties, + Vector3, +) from setuav_studio.model.parameter import ( CircularDependencyError, ParameterResolutionError, @@ -43,6 +49,7 @@ "Atmosphere", "CircularDependencyError", "Component", + "ComponentMassProperties", "ConfigurationError", "ConfigurationManager", "ConstraintChecker", @@ -52,11 +59,14 @@ "ExpressionEvaluationError", "ExpressionEvaluator", "GenericComponent", + "InertiaTensor", + "MassProperties", "ParameterResolutionError", "ParameterResolver", "ScopeProxy", "State", "System", + "Vector3", "Vehicle", "build_evaluation_context", "build_universal_scope", diff --git a/src/setuav_studio/model/component.py b/src/setuav_studio/model/component.py index 88a31d8..83e5a5b 100644 --- a/src/setuav_studio/model/component.py +++ b/src/setuav_studio/model/component.py @@ -66,6 +66,60 @@ def mass(self) -> float: def mass(self, value: float) -> None: self._raw_data["mass"] = float(value) + @property + def local_cg(self) -> dict[str, float]: + """Local center of gravity offset in mm relative to component origin.""" + val = self._raw_data.get("local_cg_mm") or self._raw_data.get("local_cg") + if not isinstance(val, dict): + ext = self.extensions.get("org.setuav.weight-balance") + if isinstance(ext, dict) and isinstance(ext.get("local_cg_mm"), dict): + val = ext["local_cg_mm"] + if not isinstance(val, dict): + val = self._raw_data.setdefault("local_cg_mm", {"x": 0.0, "y": 0.0, "z": 0.0}) + return val + + @local_cg.setter + def local_cg(self, value: dict[str, float]) -> None: + self._raw_data["local_cg_mm"] = { + "x": float(value.get("x", 0.0) or 0.0), + "y": float(value.get("y", 0.0) or 0.0), + "z": float(value.get("z", 0.0) or 0.0), + } + + @property + def inertia(self) -> dict[str, float]: + """Local inertia tensor moments and products in kg·m².""" + val = self._raw_data.get("inertia_kg_m2") or self._raw_data.get("inertia") + if not isinstance(val, dict): + ext = self.extensions.get("org.setuav.weight-balance") + if isinstance(ext, dict) and isinstance(ext.get("inertia_kg_m2"), dict): + val = ext["inertia_kg_m2"] + elif isinstance(self.parameters.get("inertia"), dict): + val = self.parameters["inertia"] + if not isinstance(val, dict): + val = self._raw_data.setdefault( + "inertia_kg_m2", + {"ixx": 0.0, "iyy": 0.0, "izz": 0.0, "ixy": 0.0, "ixz": 0.0, "iyz": 0.0}, + ) + return val + + @inertia.setter + def inertia(self, value: dict[str, float]) -> None: + self._raw_data["inertia_kg_m2"] = { + "ixx": float(value.get("ixx", 0.0) or 0.0), + "iyy": float(value.get("iyy", 0.0) or 0.0), + "izz": float(value.get("izz", 0.0) or 0.0), + "ixy": float(value.get("ixy", 0.0) or 0.0), + "ixz": float(value.get("ixz", 0.0) or 0.0), + "iyz": float(value.get("iyz", 0.0) or 0.0), + } + + @property + def inertia_tensor(self) -> Any: + from setuav_studio.model.mass import InertiaTensor + + return InertiaTensor.from_dict(self.inertia) + @property def transform(self) -> dict[str, Any]: return self._raw_data.setdefault("transform", {}) @@ -142,6 +196,7 @@ def extensions(self) -> dict[str, Any]: def get_exposed_properties(self) -> dict[str, Any]: """Return a dictionary of all property names and their current values.""" + cg = self.local_cg props: dict[str, Any] = { "id": self.id, "name": self.name, @@ -153,6 +208,9 @@ def get_exposed_properties(self) -> dict[str, Any]: "roll": self.roll, "pitch": self.pitch, "yaw": self.yaw, + "cg_x": float(cg.get("x", 0.0) or 0.0), + "cg_y": float(cg.get("y", 0.0) or 0.0), + "cg_z": float(cg.get("z", 0.0) or 0.0), } for k, v in self.parameters.items(): if isinstance(v, (int, float, str, bool)): diff --git a/src/setuav_studio/model/mass.py b/src/setuav_studio/model/mass.py new file mode 100644 index 0000000..954876e --- /dev/null +++ b/src/setuav_studio/model/mass.py @@ -0,0 +1,90 @@ +"""Physical mass, center of gravity, and inertia tensor domain models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +Vector3 = tuple[float, float, float] + + +@dataclass(frozen=True, slots=True) +class InertiaTensor: + """Symmetric inertia tensor expressed about a stated reference point.""" + + ixx: float = 0.0 + iyy: float = 0.0 + izz: float = 0.0 + ixy: float = 0.0 + ixz: float = 0.0 + iyz: float = 0.0 + + def as_matrix(self) -> tuple[tuple[float, float, float], ...]: + """Convert to a 3x3 matrix. Products of inertia use conventional negative off-diagonal.""" + return ( + (self.ixx, -self.ixy, -self.ixz), + (-self.ixy, self.iyy, -self.iyz), + (-self.ixz, -self.iyz, self.izz), + ) + + @classmethod + def from_matrix( + cls, + matrix: tuple[tuple[float, float, float], ...] | list[list[float]], + ) -> InertiaTensor: + return cls( + ixx=float(matrix[0][0]), + iyy=float(matrix[1][1]), + izz=float(matrix[2][2]), + ixy=-float(matrix[0][1]), + ixz=-float(matrix[0][2]), + iyz=-float(matrix[1][2]), + ) + + def to_dict(self) -> dict[str, float]: + return { + "ixx": self.ixx, + "iyy": self.iyy, + "izz": self.izz, + "ixy": self.ixy, + "ixz": self.ixz, + "iyz": self.iyz, + } + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> InertiaTensor: + if not isinstance(data, dict): + return cls() + return cls( + ixx=float(data.get("ixx", 0.0) or 0.0), + iyy=float(data.get("iyy", 0.0) or 0.0), + izz=float(data.get("izz", 0.0) or 0.0), + ixy=float(data.get("ixy", 0.0) or 0.0), + ixz=float(data.get("ixz", 0.0) or 0.0), + iyz=float(data.get("iyz", 0.0) or 0.0), + ) + + +@dataclass(frozen=True, slots=True) +class MassProperties: + """Aggregate mass properties of a body or aircraft.""" + + mass_kg: float + cg_body_m: Vector3 + inertia_cg_kg_m2: InertiaTensor + + +@dataclass(frozen=True, slots=True) +class ComponentMassProperties: + """Resolved mass properties of an individual aircraft component.""" + + component_id: str + component_name: str + mass_kg: float + cg_local_m: Vector3 + cg_body_m: Vector3 + inertia_local_kg_m2: InertiaTensor + source: str + quality: str + warnings: tuple[str, ...] = () + component_type: str = "" diff --git a/src/setuav_studio/ui/editor/__init__.py b/src/setuav_studio/ui/editor/__init__.py index f7b44cc..553baa6 100644 --- a/src/setuav_studio/ui/editor/__init__.py +++ b/src/setuav_studio/ui/editor/__init__.py @@ -5,11 +5,13 @@ from setuav_studio.ui.editor.component import BaseComponentEditor from setuav_studio.ui.editor.envelope import EnvelopeEditor from setuav_studio.ui.editor.instance import InstanceEditor +from setuav_studio.ui.editor.mass import MassPropertiesEditor from setuav_studio.ui.editor.transform import TransformEditor __all__ = [ "BaseComponentEditor", "EnvelopeEditor", "InstanceEditor", + "MassPropertiesEditor", "TransformEditor", ] diff --git a/src/setuav_studio/ui/editor/mass.py b/src/setuav_studio/ui/editor/mass.py new file mode 100644 index 0000000..5b00b50 --- /dev/null +++ b/src/setuav_studio/ui/editor/mass.py @@ -0,0 +1,413 @@ +"""Properties editor for a component's declared mass properties.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QTableWidget, + QVBoxLayout, + QWidget, +) + +from setuav_studio.ui.icons import get_icon, set_label_icon +from setuav_studio.ui.widget.spinbox import NumericSpinBox, set_table_spinbox +from setuav_studio.ui.widget.table import PropertyTableMixin +from setuav_studio_sdk import StudioAPI + +WB_EXTENSION_ID = "org.setuav.weight-balance" +EXTENSION_ID = WB_EXTENSION_ID + + +class MassPropertiesEditor(PropertyTableMixin, QWidget): + """Table-based mass editor styled like the other Setuav property docks.""" + + table_scroll_policy_off = True + table_max_visible_rows = None + + def __init__( + self, + api: StudioAPI, + selection: dict[str, Any], + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("core.mass_properties_editor") + self._api = api + component_id = str(selection.get("component_id") or "") + self._component = ( + api.current_project.get_component(component_id) + if api.current_project is not None and component_id + else None + ) + self._loading = False + self._pending_before: dict[str, Any] | None = None + self._commit_timer = QTimer(self) + self._commit_timer.setSingleShot(True) + self._commit_timer.timeout.connect(self._commit_pending) + self.destroyed.connect(self._commit_timer.stop) + self._section_icons: list[tuple[QLabel, str]] = [] + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + + content = QWidget() + self._content_layout = QVBoxLayout(content) + self._content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self._content_layout.setContentsMargins(6, 6, 6, 8) + self._content_layout.setSpacing(10) + + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + scroll.setWidget(content) + root_layout.addWidget(scroll) + + self._create_mass_section(component_id) + self._create_cg_section() + self._create_inertia_section() + self._content_layout.addStretch(1) + + # Compatibility handle for callers of the old explicit-Apply editor. + # It is intentionally not visible; edits are committed automatically. + self.apply_button = QPushButton(self) + self.apply_button.setIcon(get_icon("fa6s.check")) + self.apply_button.setVisible(False) + self.apply_button.clicked.connect(self._commit_pending) + + if self._component is not None: + self._load_component(self._component) + + def update_theme_style(self) -> None: + for label, icon_name in self._section_icons: + set_label_icon(label, icon_name) + + def _create_section(self, title: str, icon_name: str) -> QVBoxLayout: + section = QWidget() + section.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Maximum, + ) + layout = QVBoxLayout(section) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + header = QWidget(section) + header.setProperty("sectionHeader", True) + header.setFixedHeight(20) + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(5) + + icon_label = QLabel(header) + set_label_icon(icon_label, icon_name) + icon_label.setFixedSize(14, 14) + self._section_icons.append((icon_label, icon_name)) + header_layout.addWidget(icon_label) + header_layout.addWidget(QLabel(title, header)) + header_layout.addStretch(1) + + layout.addWidget(header) + self._content_layout.addWidget(section) + return layout + + def _create_mass_section(self, component_id: str) -> None: + layout = self._create_section("Mass", "fa6s.cubes-stacked") + self.mass_table = self._property_table( + [ + ("component", "Component"), + ("mass", "Mass"), + ] + ) + component_name = ( + str(self._component.get("name") or component_id) + if self._component is not None + else "Missing component" + ) + self._set_property_value( + self.mass_table, + "component", + component_name, + editable=False, + ) + self.mass_g = self._set_numeric_cell( + self.mass_table, + "mass", + minimum=0.0, + maximum=1_000_000_000.0, + step=1.0, + decimals=3, + quantity="mass", + suffix="g", + on_changed=self._on_field_changed, + ) + layout.addWidget(self.mass_table) + + def _create_cg_section(self) -> None: + layout = self._create_section("Local Center of Gravity", "fa6s.crosshairs") + self.cg_table = QTableWidget(1, 3) + self.cg_table.setHorizontalHeaderLabels(["X", "Y", "Z"]) + self.cg_table.setVerticalHeaderLabels(["Position"]) + self.cg_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.cg_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) + self.cg_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.cg_table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.cg_table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.cg_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.cg_table.horizontalHeader().setFixedHeight(23) + self.cg_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + self.cg_table.verticalHeader().setDefaultSectionSize(23) + self.cg_table.verticalHeader().setMinimumWidth(82) + self.cg_table.setAlternatingRowColors(True) + self.cg_table.setFixedHeight(48) + + self.cg_spins = { + axis: set_table_spinbox( + self.cg_table, + 0, + column, + 0.0, + min_val=-10_000_000.0, + max_val=10_000_000.0, + step=1.0, + decimals=3, + quantity="length", + suffix="mm", + on_changed=self._on_field_changed, + ) + for column, axis in enumerate(("x", "y", "z")) + } + layout.addWidget(self.cg_table) + + def _create_inertia_section(self) -> None: + layout = self._create_section("Inertia Tensor", "fa6s.cube") + moment_keys = ("ixx", "iyy", "izz") + product_keys = ("ixy", "ixz", "iyz") + self.inertia_moments_table = self._inertia_row_table( + [key.upper() for key in moment_keys], + "Moments", + ) + self.inertia_products_table = self._inertia_row_table( + [key.upper() for key in product_keys], + "Products", + ) + self.inertia_spins = {} + for table, keys, minimum in ( + (self.inertia_moments_table, moment_keys, 0.0), + (self.inertia_products_table, product_keys, -1_000_000.0), + ): + self.inertia_spins.update( + { + key: set_table_spinbox( + table, + 0, + column, + 0.0, + min_val=minimum, + max_val=1_000_000.0, + step=0.000001, + decimals=8, + quantity="inertia", + suffix="kg·m²", + on_changed=self._on_field_changed, + ) + for column, key in enumerate(keys) + } + ) + layout.addWidget(self.inertia_moments_table) + layout.addWidget(self.inertia_products_table) + + @staticmethod + def _inertia_row_table(headers: list[str], row_label: str) -> QTableWidget: + table = QTableWidget(1, 3) + table.setHorizontalHeaderLabels(headers) + table.setVerticalHeaderLabels([row_label]) + table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + table.horizontalHeader().setFixedHeight(23) + table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + table.verticalHeader().setDefaultSectionSize(23) + table.verticalHeader().setMinimumWidth(82) + table.setAlternatingRowColors(True) + table.setFixedHeight(48) + return table + + def _set_numeric_cell( + self, + table: QTableWidget, + key: str, + *, + minimum: float, + maximum: float, + step: float, + decimals: int, + quantity: str = "", + suffix: str, + on_changed: Any | None = None, + ) -> NumericSpinBox: + for row in range(table.rowCount()): + if self._property_key(table, row) == key: + return set_table_spinbox( + table, + row, + 1, + 0.0, + min_val=minimum, + max_val=maximum, + step=step, + decimals=decimals, + quantity=quantity, + suffix=suffix, + on_changed=on_changed, + ) + raise KeyError(f"Unknown mass-properties field: {key}") + + def _load_component(self, component: dict[str, Any]) -> None: + self._loading = True + try: + source = self._source_component(component) + parameters = component.get("parameters") + parameters = parameters if isinstance(parameters, dict) else {} + source_parameters = source.get("parameters") + source_parameters = source_parameters if isinstance(source_parameters, dict) else {} + mass = component.get( + "mass", + parameters.get( + "mass", + source.get("mass", source_parameters.get("mass", 0.0)), + ), + ) + self.mass_g.setValue(_number(mass)) + + # 1. CG resolution: check root first, then extensions, then envelope + cg = ( + component.get("local_cg_mm") + or component.get("local_cg") + or source.get("local_cg_mm") + ) + source_extensions = source.get("extensions") + source_extensions = source_extensions if isinstance(source_extensions, dict) else {} + source_definition = source_extensions.get(WB_EXTENSION_ID) + source_definition = source_definition if isinstance(source_definition, dict) else {} + extensions = component.get("extensions") + extensions = extensions if isinstance(extensions, dict) else {} + own_definition = extensions.get(WB_EXTENSION_ID) + own_definition = own_definition if isinstance(own_definition, dict) else {} + definition = dict(source_definition) + definition.update(own_definition) + if not isinstance(cg, dict): + cg = definition.get("local_cg_mm") + if not isinstance(cg, dict): + envelope = component.get("envelope") or source.get("envelope") + if isinstance(envelope, dict): + candidate = envelope.get("offset_mm") + if isinstance(candidate, dict): + cg = candidate + cg = cg if isinstance(cg, dict) else {} + for axis, spin in self.cg_spins.items(): + spin.setValue(_number(cg.get(axis))) + + # 2. Inertia resolution: check root first, then extensions, then parameters + inertia = ( + component.get("inertia_kg_m2") + or component.get("inertia") + or source.get("inertia_kg_m2") + ) + if not isinstance(inertia, dict): + inertia = definition.get( + "inertia_kg_m2", + parameters.get("inertia", source_parameters.get("inertia")), + ) + inertia = inertia if isinstance(inertia, dict) else {} + for key, spin in self.inertia_spins.items(): + spin.setValue(_number(inertia.get(key))) + finally: + self._loading = False + + def _on_field_changed(self, _value: float) -> None: + component = self._component + if self._loading or component is None: + return + if self._pending_before is None: + self._pending_before = deepcopy(component) + mass_g = self.mass_g.value() + cg = {axis: spin.value() for axis, spin in self.cg_spins.items()} + inertia = {key: spin.value() for key, spin in self.inertia_spins.items()} + + def change() -> None: + component["mass"] = mass_g + component.pop("mass_expression", None) + parameters = component.get("parameters") + if isinstance(parameters, dict) and "mass" in parameters: + parameters["mass"] = mass_g + + # Write native Core fields directly on component + component["local_cg_mm"] = cg + component["inertia_kg_m2"] = inertia + + # Backward-compatibility mirror in extensions + extensions = component.setdefault("extensions", {}) + if not isinstance(extensions, dict): + extensions = {} + component["extensions"] = extensions + definition = extensions.setdefault(WB_EXTENSION_ID, {}) + if not isinstance(definition, dict): + definition = {} + extensions[WB_EXTENSION_ID] = definition + definition.update( + { + "mass_source": "declared", + "local_cg_mm": cg, + "inertia_kg_m2": inertia, + } + ) + + change() + self._commit_timer.start(0) + + def _commit_pending(self) -> None: + self._commit_timer.stop() + component = self._component + before = self._pending_before + if component is None or before is None: + return + after = deepcopy(component) + self._pending_before = None + component.clear() + component.update(before) + self._api.edit_component( + component, + "Edit component mass definition", + lambda: component.update(deepcopy(after)), + ) + + def _source_component(self, component: dict[str, Any]) -> dict[str, Any]: + if component.get("kind") != "instance" or self._api.current_project is None: + return component + source_id = component.get("source") + source = self._api.current_project.get_component(str(source_id or "")) + return source if source is not None else component + + +def _number(value: object) -> float: + if value is None or value == "": + return 0.0 + try: + if isinstance(value, (int, float, str)): + return float(value) + return float(str(value)) + except (TypeError, ValueError): + return 0.0 diff --git a/src/setuav_studio/ui/shell/native_registrations.py b/src/setuav_studio/ui/shell/native_registrations.py index 42682d0..6e04f76 100644 --- a/src/setuav_studio/ui/shell/native_registrations.py +++ b/src/setuav_studio/ui/shell/native_registrations.py @@ -7,6 +7,7 @@ from setuav_studio.ui.editor import ( EnvelopeEditor, InstanceEditor, + MassPropertiesEditor, TransformEditor, ) from setuav_studio.ui.parameter import ProjectParametersPanel @@ -61,6 +62,29 @@ def _transform_tree_nodes( return tuple(nodes) +def _mass_tree_nodes( + component: dict[str, Any], +) -> tuple[ComponentTreeNodeContribution, ...]: + component_id = str(component.get("id") or "") + if not component_id: + return () + node_id = f"{component_id}:mass-properties" + return ( + ComponentTreeNodeContribution( + id=node_id, + title="Mass", + selection={ + "id": node_id, + "name": "Mass", + "kind": "mass-properties", + "component_id": component_id, + }, + icon="mass", + tooltip=f"Mass, local CG and inertia for {component.get('name') or component_id}", + ), + ) + + def register_native_contributions(api: StudioAPI) -> None: """Register built-in native panels, tree providers, and kind editors into the StudioAPI.""" # 1. Native Panels @@ -97,6 +121,10 @@ def register_native_contributions(api: StudioAPI) -> None: "org.setuav.studio.core.transform", _transform_tree_nodes, ) + api.register_component_tree_provider( + "org.setuav.studio.core.mass", + _mass_tree_nodes, + ) # 3. Native Kind Editors api.register_kind_editor( @@ -111,3 +139,7 @@ def register_native_contributions(api: StudioAPI) -> None: "envelope", lambda selection: EnvelopeEditor(api, selection), ) + api.register_kind_editor( + "mass-properties", + lambda selection: MassPropertiesEditor(api, selection), + ) diff --git a/tests/core/test_model.py b/tests/core/test_model.py index eced4e7..aefd8cf 100644 --- a/tests/core/test_model.py +++ b/tests/core/test_model.py @@ -146,6 +146,52 @@ def test_vehicle_backward_compatibility_flat_components(self) -> None: self.assertEqual(len(vehicle.all_components()), 2) self.assertEqual(vehicle.get_component("c1").name, "Wing") + def test_component_mass_properties_and_inertia_model(self) -> None: + from setuav_studio.model import InertiaTensor + + tensor = InertiaTensor(ixx=0.05, iyy=0.08, izz=0.12, ixy=0.001) + matrix = tensor.as_matrix() + self.assertEqual(matrix[0][0], 0.05) + self.assertEqual(matrix[0][1], -0.001) + from_mat = InertiaTensor.from_matrix(matrix) + self.assertAlmostEqual(from_mat.ixx, 0.05) + self.assertAlmostEqual(from_mat.ixy, 0.001) + + comp = Component( + { + "id": "battery", + "name": "LiPo Pack", + "mass": 450.0, + "local_cg_mm": {"x": 50.0, "y": 0.0, "z": -10.0}, + "inertia_kg_m2": {"ixx": 0.01, "iyy": 0.02, "izz": 0.03}, + } + ) + self.assertEqual(comp.mass, 450.0) + self.assertEqual(comp.local_cg["x"], 50.0) + self.assertEqual(comp.inertia["izz"], 0.03) + self.assertIsInstance(comp.inertia_tensor, InertiaTensor) + self.assertAlmostEqual(comp.inertia_tensor.izz, 0.03) + + # Setter updates underlying raw dict + comp.local_cg = {"x": 60.0, "y": 5.0, "z": 0.0} + self.assertEqual(comp.raw_data["local_cg_mm"]["x"], 60.0) + + # Legacy extensions fallback + legacy_comp = Component( + { + "id": "legacy_motor", + "mass": 120.0, + "extensions": { + "org.setuav.weight-balance": { + "local_cg_mm": {"x": 10.0, "y": 0.0, "z": 0.0}, + "inertia_kg_m2": {"ixx": 0.005}, + } + }, + } + ) + self.assertEqual(legacy_comp.local_cg["x"], 10.0) + self.assertEqual(legacy_comp.inertia["ixx"], 0.005) + if __name__ == "__main__": unittest.main() diff --git a/tests/core/test_plugins.py b/tests/core/test_plugins.py index b18c8f2..095080f 100644 --- a/tests/core/test_plugins.py +++ b/tests/core/test_plugins.py @@ -19,6 +19,7 @@ ) from setuav_studio.project import ProjectDocument from setuav_studio.ui.editor.envelope import EnvelopeEditor +from setuav_studio.ui.editor.mass import MassPropertiesEditor from setuav_studio.ui.editor.transform import TransformEditor from setuav_studio.ui.project_explorer import ProjectExplorer from setuav_studio.ui.shell.native_registrations import register_native_contributions @@ -147,6 +148,20 @@ def test_core_plugin_contributes_transform_tree_node_and_editor(self) -> None: self.assertEqual(envelope["size_mm"], {"x": 60.0, "y": 30.0, "z": 15.0}) self.assertAlmostEqual(envelope_editor.volume_value(), 27_000.0) + # 3. Native Mass Properties contribution + mass_contribution = self.api.component_tree_nodes(component)[2] + self.assertEqual(mass_contribution.id, "motor:mass-properties") + self.assertEqual(mass_contribution.title, "Mass") + self.assertEqual(mass_contribution.icon, "mass") + mass_editor = self.api.create_component_editor(mass_contribution.selection) + self.assertIsInstance(mass_editor, MassPropertiesEditor) + self.addCleanup(mass_editor.deleteLater) + self.assertTrue(mass_editor.mass_g.isEnabled()) + mass_editor.mass_g.setValue(320.0) + self.assertEqual(component["mass"], 320.0) + self.api.undo() + self.assertNotIn("mass", component) + def test_project_explorer_describes_instance_source_by_name(self) -> None: components = [ {"id": "wing-left", "name": "Left Main Wing", "kind": "component"}, diff --git a/tests/weight_balance/test_weight_balance.py b/tests/weight_balance/test_weight_balance.py index 17965bb..f9c33cc 100644 --- a/tests/weight_balance/test_weight_balance.py +++ b/tests/weight_balance/test_weight_balance.py @@ -365,6 +365,7 @@ def test_mass_definition_editor_updates_component_with_undo(self) -> None: api = StudioAPI() api._host.bind_panel_handlers(lambda _panel: None) api._host.bind_workspace_handlers(lambda _workspace: None) + register_native_contributions(api) plugin = WeightBalancePlugin() plugin.activate(api) component = { @@ -375,7 +376,13 @@ def test_mass_definition_editor_updates_component_with_undo(self) -> None: } project = _project({"components": [component]}) api._host.set_project(project) - contribution = api.component_tree_nodes(component)[0] + mass_nodes = [ + n + for n in api.component_tree_nodes(component) + if n.selection.get("kind") == "mass-properties" + ] + self.assertEqual(len(mass_nodes), 1) + contribution = mass_nodes[0] self.assertEqual(contribution.icon, "mass") definition = api.create_component_editor(contribution.selection) self.assertIsInstance(definition, MassPropertiesEditor) @@ -431,6 +438,7 @@ def test_project_tree_child_opens_mass_properties_in_properties_panel(self) -> N api = StudioAPI() api._host.bind_panel_handlers(lambda _panel: None) api._host.bind_workspace_handlers(lambda _workspace: None) + register_native_contributions(api) plugin = WeightBalancePlugin() plugin.activate(api) component = {"id": "payload", "name": "Payload", "mass": 500} @@ -497,6 +505,7 @@ def test_cg_view_marker_click_selects_mass_properties(self) -> None: api = StudioAPI() api._host.bind_panel_handlers(lambda _panel: None) api._host.bind_workspace_handlers(lambda _workspace: None) + register_native_contributions(api) plugin = WeightBalancePlugin() plugin.activate(api) component = {"id": "battery_1", "name": "Main Battery", "mass": 450} @@ -550,6 +559,45 @@ def test_cg_view_marker_click_selects_mass_properties(self) -> None: legend_texts = [text for _, _, text in view_dock._legend_labels] self.assertIn("Point Mass", legend_texts) + def test_mass_properties_remain_visible_when_weight_balance_plugin_deactivated(self) -> None: + api = StudioAPI() + api._host.bind_panel_handlers(lambda _panel: None) + api._host.bind_workspace_handlers(lambda _workspace: None) + register_native_contributions(api) + + plugin = WeightBalancePlugin() + plugin.activate(api) + + component = {"id": "payload", "name": "Payload", "mass": 500} + project = _project({"name": "Test", "components": [component]}) + api._host.set_project(project) + + # Deactivate plugin + plugin.deactivate(api) + + # Tree nodes still contain mass-properties + mass_nodes = [ + n + for n in api.component_tree_nodes(component) + if n.selection.get("kind") == "mass-properties" + ] + self.assertEqual(len(mass_nodes), 1) + contribution = mass_nodes[0] + self.assertEqual(contribution.title, "Mass") + self.assertEqual(contribution.icon, "mass") + + # Project Explorer still has the mass tree item + explorer = ProjectExplorer(api) + properties = PropertiesPanel(api) + mass_item = explorer._item_map["payload:mass-properties"] + self.assertIsNotNone(mass_item) + explorer.setCurrentItem(mass_item) + get_qapp().processEvents() + + self.assertEqual(api.current_selection["kind"], "mass-properties") + self.assertIsInstance(properties._current_widget, MassPropertiesEditor) + self.assertTrue(properties._current_widget.mass_g.isEnabled()) + if __name__ == "__main__": unittest.main()