diff --git a/pyproject.toml b/pyproject.toml index 854c8f9..b36b2be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,7 +162,6 @@ include = [ "src/setuav_studio/units/quantities.py", "src/setuav_studio/units/unit.py", "src/setuav_studio/model/component.py", - "src/setuav_studio/model/configuration.py", "src/setuav_studio/model/constraint.py", "src/setuav_studio/model/data.py", "src/setuav_studio/model/environment.py", diff --git a/src/plugins/geometry/engine/envelope.py b/src/plugins/geometry/engine/envelope.py index 8c3190c..8f17ae5 100644 --- a/src/plugins/geometry/engine/envelope.py +++ b/src/plugins/geometry/engine/envelope.py @@ -70,11 +70,6 @@ def sync_project_geometry_envelopes(project: Any) -> int: return 0 all_components = list(components) - for cfg in data.get("configurations", []): - if isinstance(cfg, dict): - for added in cfg.get("added_components", []): - if isinstance(added, dict): - all_components.append(added) by_id: dict[str, dict[str, Any]] = { str(c.get("id")): c for c in all_components if isinstance(c, dict) and c.get("id") diff --git a/src/plugins/geometry/viewport/scene.py b/src/plugins/geometry/viewport/scene.py index 3a487e6..6c1dca1 100644 --- a/src/plugins/geometry/viewport/scene.py +++ b/src/plugins/geometry/viewport/scene.py @@ -69,11 +69,13 @@ def _project_items(project: Any) -> dict[str, dict[str, Any]] | None: components = project_data.get("components") if isinstance(project_data, dict) else None if not isinstance(components, list): return None - from setuav_studio.model.configuration import ConfigurationManager + from setuav_studio.model.parameter import ParameterResolver - cfg_mgr = ConfigurationManager(project_data) + resolver = ParameterResolver() + raw_params = project_data.get("parameters", {}) if isinstance(project_data, dict) else {} + resolved_params = resolver.resolve_all(raw_params) if isinstance(raw_params, dict) else {} return { - item["id"]: cfg_mgr.get_resolved_component(item) + item["id"]: resolver.evaluate_component_parameters(item, resolved_params) for item in components if isinstance(item, dict) and isinstance(item.get("id"), str) } diff --git a/src/setuav_studio/model/__init__.py b/src/setuav_studio/model/__init__.py index 468875b..e4f3cd7 100644 --- a/src/setuav_studio/model/__init__.py +++ b/src/setuav_studio/model/__init__.py @@ -7,10 +7,6 @@ Component, GenericComponent, ) -from setuav_studio.model.configuration import ( - ConfigurationError, - ConfigurationManager, -) from setuav_studio.model.constraint import ( ConstraintChecker, ConstraintResult, @@ -50,8 +46,6 @@ "CircularDependencyError", "Component", "ComponentMassProperties", - "ConfigurationError", - "ConfigurationManager", "ConstraintChecker", "ConstraintResult", "Data", diff --git a/src/setuav_studio/model/configuration.py b/src/setuav_studio/model/configuration.py deleted file mode 100644 index 641c206..0000000 --- a/src/setuav_studio/model/configuration.py +++ /dev/null @@ -1,562 +0,0 @@ -"""Configuration management using a clean Delta (diff/overrides) model. - -Configurations inherit all base project components and parameters, storing only: -- parameter_overrides: dictionary of path -> value (e.g. project.parameters.* or comp_id.parameters.*) -- excluded_components: list of component IDs removed in this configuration -- added_components: list of new component definitions added in this configuration -- component_overrides: dictionary of comp_id -> changed properties (name, transform, etc.) -""" - -from __future__ import annotations - -import contextlib -import copy -import re -from collections.abc import Callable -from typing import Any - -from setuav_studio.model.parameter import ParameterResolver - - -class ConfigurationError(Exception): - """Raised when configuration operations fail.""" - - -def parse_path_segments(path: str) -> list[str | int]: - """Parse a dot/bracket notation path into list of key/index segments.""" - tokens = re.findall(r"[^.\[\]]+|\[\d+\]", path) - segments: list[str | int] = [] - for token in tokens: - if token.startswith("[") and token.endswith("]"): - segments.append(int(token[1:-1])) - else: - segments.append(token) - return segments - - -def get_by_path(target: Any, path: str) -> Any: - """Retrieve value from nested dict/list using dot/bracket path.""" - segments = parse_path_segments(path) - curr = target - for seg in segments: - if isinstance(seg, int): - if not isinstance(curr, (list, tuple)) or seg >= len(curr): - raise IndexError(f"Index {seg} out of bounds in path '{path}'") - curr = curr[seg] - else: - if not isinstance(curr, dict) or seg not in curr: - raise KeyError(f"Key '{seg}' not found in path '{path}'") - curr = curr[seg] - return curr - - -def _ensure_intermediate_segment(curr: Any, seg: str | int, next_seg: str | int) -> Any: - """Ensure intermediate list or dict container exists.""" - if isinstance(seg, int): - if not isinstance(curr, list): - raise TypeError("Expected list container") - while len(curr) <= seg: - curr.append({} if isinstance(next_seg, str) else []) - return curr[seg] - - if not isinstance(curr, dict): - raise TypeError("Expected dict container") - if seg not in curr or not isinstance(curr[seg], (dict, list)): - curr[seg] = [] if isinstance(next_seg, int) else {} - return curr[seg] - - -def set_by_path(target: Any, path: str, value: Any) -> None: - """Set value in nested dict/list using dot/bracket path.""" - segments = parse_path_segments(path) - if not segments: - return - - curr = target - for i, seg in enumerate(segments[:-1]): - curr = _ensure_intermediate_segment(curr, seg, segments[i + 1]) - - last_seg = segments[-1] - if isinstance(last_seg, int): - if not isinstance(curr, list): - raise TypeError(f"Expected list at final segment in path '{path}'") - while len(curr) <= last_seg: - curr.append(None) - curr[last_seg] = value - else: - if not isinstance(curr, dict): - raise TypeError(f"Expected dict at final segment in path '{path}'") - curr[last_seg] = value - - -def _apply_component_overrides( - components: list[dict[str, Any]], comp_overrides: dict[str, Any] -) -> None: - """Apply top-level component property overrides in place.""" - for comp in components: - cid = comp.get("id") - if cid in comp_overrides: - for k, v in comp_overrides[cid].items(): - comp[k] = copy.deepcopy(v) - - -def _apply_parameter_overrides( - components: list[dict[str, Any]], - parameters: dict[str, Any], - param_overrides: dict[str, Any], -) -> None: - """Apply project and component parameter overrides in place.""" - for path, value in param_overrides.items(): - if path.startswith("project.parameters."): - param_key = path[len("project.parameters.") :] - existing_val = parameters.get(param_key) - if isinstance(existing_val, dict): - target_val: Any = copy.deepcopy(existing_val) - target_val["value"] = copy.deepcopy(value) - else: - target_val = copy.deepcopy(value) - parameters[param_key] = target_val - else: - for comp in components: - cid = str(comp.get("id") or "") - if cid and path.startswith(f"{cid}."): - sub_path = path[len(cid) + 1 :] - with contextlib.suppress(Exception): - set_by_path(comp, sub_path, copy.deepcopy(value)) - break - - -def apply_configuration_delta( - base_components: list[dict[str, Any]], - base_parameters: dict[str, Any], - base_assemblies: list[dict[str, Any]], - config_dict: dict[str, Any], -) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]: - """Materialize full components and parameters for a configuration by applying its delta to base.""" - if "components" in config_dict and isinstance(config_dict["components"], list): - components = copy.deepcopy(config_dict["components"]) - parameters = copy.deepcopy(config_dict.get("parameters", base_parameters)) - assemblies = copy.deepcopy(config_dict.get("assemblies", base_assemblies)) - return components, parameters, assemblies - - components = copy.deepcopy(base_components) - parameters = copy.deepcopy(base_parameters) - assemblies = copy.deepcopy(base_assemblies) - - excluded_ids = set(config_dict.get("excluded_components", [])) - if excluded_ids: - components = [c for c in components if c.get("id") not in excluded_ids] - - _apply_component_overrides(components, config_dict.get("component_overrides", {})) - _apply_parameter_overrides(components, parameters, config_dict.get("parameter_overrides", {})) - - added_components = copy.deepcopy(config_dict.get("added_components", [])) - components.extend(added_components) - - return components, parameters, assemblies - - -def compute_configuration_delta( - base_components: list[dict[str, Any]], - base_parameters: dict[str, Any], - current_components: list[dict[str, Any]], - current_parameters: dict[str, Any], -) -> dict[str, Any]: - """Compute the minimal delta (overrides, additions, exclusions) between base and current state.""" - base_comp_map = {c["id"]: c for c in base_components if isinstance(c, dict) and "id" in c} - curr_comp_map = {c["id"]: c for c in current_components if isinstance(c, dict) and "id" in c} - - excluded_components = [cid for cid in base_comp_map if cid not in curr_comp_map] - added_components = [c for cid, c in curr_comp_map.items() if cid not in base_comp_map] - - component_overrides: dict[str, Any] = {} - parameter_overrides: dict[str, Any] = {} - - # Check project parameter differences - _diff_dict_paths( - base_parameters, - current_parameters, - prefix="project.parameters", - out=parameter_overrides, - ) - - # Check common components - for cid in base_comp_map: - if cid not in curr_comp_map: - continue - base_c = base_comp_map[cid] - curr_c = curr_comp_map[cid] - - # Check top-level properties - overrides: dict[str, Any] = {} - for prop in ("name", "parent", "attach_to", "transform", "mass"): - if curr_c.get(prop) != base_c.get(prop): - overrides[prop] = copy.deepcopy(curr_c.get(prop)) - if overrides: - component_overrides[cid] = overrides - - # Check parameter differences - _diff_dict_paths( - base_c.get("parameters", {}), - curr_c.get("parameters", {}), - prefix=f"{cid}.parameters", - out=parameter_overrides, - ) - - delta: dict[str, Any] = { - "parameter_overrides": parameter_overrides, - "excluded_components": excluded_components, - "added_components": added_components, - "component_overrides": component_overrides, - } - return delta - - -def _diff_dict_paths(base: Any, current: Any, prefix: str, out: dict[str, Any]) -> None: - """Helper to find leaf differences between two dict structures.""" - if isinstance(base, dict) and isinstance(current, dict): - all_keys = set(base.keys()) | set(current.keys()) - for k in all_keys: - new_prefix = f"{prefix}.{k}" - if k not in base: - out[new_prefix] = current[k] - elif k not in current: - out[new_prefix] = None - else: - _diff_dict_paths(base[k], current[k], new_prefix, out) - elif base != current: - out[prefix] = current - - -class ConfigurationManager: - """Manages project configurations using a clean Delta (diff/override) model.""" - - def __init__( - self, - project_data: dict[str, Any], - resolver: ParameterResolver | None = None, - ) -> None: - self.project_data = project_data - self.resolver = resolver or ParameterResolver() - self._active_id: str | None = None - self._listeners: list[Callable[[], None]] = [] - - # Snapshot of clean base state - self._base_state: dict[str, Any] = { - "components": copy.deepcopy(self.project_data.get("components", [])), - "parameters": copy.deepcopy(self.project_data.get("parameters", {})), - "assemblies": copy.deepcopy(self.project_data.get("assemblies", [])), - } - - def add_change_listener(self, callback: Callable[[], None]) -> None: - """Register a callback for configuration changes.""" - if callback not in self._listeners: - self._listeners.append(callback) - - def remove_change_listener(self, callback: Callable[[], None]) -> None: - """Unregister a callback.""" - if callback in self._listeners: - self._listeners.remove(callback) - - def _notify(self) -> None: - for cb in list(self._listeners): - with contextlib.suppress(Exception): - cb() - - def get_configurations(self) -> list[dict[str, Any]]: - """Return list of configuration dictionaries.""" - return self.project_data.setdefault("configurations", []) - - def get_configuration(self, config_id: str) -> dict[str, Any] | None: - """Get configuration dictionary by its ID.""" - for cfg in self.get_configurations(): - if cfg.get("id") == config_id: - return cfg - return None - - def get_active_id(self) -> str | None: - """Return currently active configuration ID, or None for base.""" - if self._active_id is not None and self.get_configuration(self._active_id) is None: - self._active_id = None - return self._active_id - - def get_active_configuration(self) -> dict[str, Any] | None: - """Return currently active configuration dict, or None for base.""" - aid = self.get_active_id() - if aid is None: - return None - return self.get_configuration(aid) - - def sync_current_state_to_active(self) -> None: - """Sync working state back into the active configuration's delta or base snapshot.""" - current_components = self.project_data.get("components", []) - current_parameters = self.project_data.get("parameters", {}) - current_assemblies = self.project_data.get("assemblies", []) - current_expressions = current_parameters.get("_expressions", {}) - - active_id = self.get_active_id() - if active_id is None: - self._base_state = { - "components": copy.deepcopy(current_components), - "parameters": copy.deepcopy(current_parameters), - "assemblies": copy.deepcopy(current_assemblies), - } - self._base_state["parameters"]["_expressions"] = copy.deepcopy(current_expressions) - else: - cfg = self.get_configuration(active_id) - if cfg is not None: - delta = compute_configuration_delta( - self._base_state["components"], - self._base_state["parameters"], - current_components, - current_parameters, - ) - cfg["parameter_overrides"] = delta["parameter_overrides"] - cfg["excluded_components"] = delta["excluded_components"] - cfg["added_components"] = delta["added_components"] - cfg["component_overrides"] = delta["component_overrides"] - # Preserve expressions through config delta - if current_expressions: - cfg.setdefault("_expressions", {}) - for k, v in current_expressions.items(): - if k not in cfg["_expressions"]: - cfg["_expressions"][k] = copy.deepcopy(v) - # Clean up legacy redundant full snapshots if present - cfg.pop("components", None) - cfg.pop("parameters", None) - cfg.pop("assemblies", None) - - def set_active_id(self, config_id: str | None) -> None: - """Switch active configuration, materializing the state from base + delta.""" - if config_id is not None and self.get_configuration(config_id) is None: - raise KeyError(f"Configuration '{config_id}' does not exist.") - - if self._active_id == config_id: - return - - # 1. Sync current working state - self.sync_current_state_to_active() - - # 2. Materialize target configuration state - if config_id is None: - self.project_data["components"] = copy.deepcopy(self._base_state["components"]) - self.project_data["parameters"] = copy.deepcopy(self._base_state["parameters"]) - # Restore expressions from base state if present - if "_expressions" in self._base_state and isinstance( - self._base_state["_expressions"], dict - ): - self.project_data["parameters"].setdefault("_expressions", {}) - self.project_data["parameters"]["_expressions"].update( - copy.deepcopy(self._base_state["_expressions"]) - ) - self.project_data["assemblies"] = copy.deepcopy(self._base_state.get("assemblies", [])) - else: - target_cfg = self.get_configuration(config_id) - if target_cfg is not None: - comps, params, assems = apply_configuration_delta( - self._base_state["components"], - self._base_state["parameters"], - self._base_state.get("assemblies", []), - target_cfg, - ) - self.project_data["components"] = comps - self.project_data["parameters"] = params - # Restore expressions from config if present - if "_expressions" in target_cfg and isinstance(target_cfg["_expressions"], dict): - self.project_data["parameters"].setdefault("_expressions", {}) - self.project_data["parameters"]["_expressions"].update( - copy.deepcopy(target_cfg["_expressions"]) - ) - self.project_data["assemblies"] = assems - - self._active_id = config_id - self._notify() - - def create_configuration( - self, - name: str, - tag: str, - description: str = "", - is_default: bool = False, - config_id: str | None = None, - parameter_overrides: dict[str, Any] | None = None, - excluded_components: list[str] | None = None, - added_components: list[dict[str, Any]] | None = None, - component_overrides: dict[str, Any] | None = None, - **_kwargs: Any, - ) -> dict[str, Any]: - """Create a new delta-based configuration.""" - self.sync_current_state_to_active() - - configs = self.get_configurations() - cid = config_id or tag.lower().strip() - if not cid: - cid = f"config-{len(configs) + 1}" - - base_cid = cid - counter = 1 - existing_ids = {c.get("id") for c in configs} - while cid in existing_ids: - cid = f"{base_cid}-{counter}" - counter += 1 - - if is_default: - for c in configs: - c["is_default"] = False - - new_config: dict[str, Any] = { - "id": cid, - "name": name, - "tag": tag, - "description": description, - "parameter_overrides": dict(parameter_overrides or {}), - "excluded_components": list(excluded_components or []), - "added_components": list(added_components or []), - "component_overrides": dict(component_overrides or {}), - "is_default": is_default, - } - configs.append(new_config) - self._notify() - return new_config - - def update_configuration(self, config_id: str, **kwargs: Any) -> dict[str, Any]: - """Update metadata fields of an existing configuration.""" - cfg = self.get_configuration(config_id) - if cfg is None: - raise KeyError(f"Configuration '{config_id}' not found.") - - if kwargs.get("is_default"): - for c in self.get_configurations(): - c["is_default"] = False - - for k, v in kwargs.items(): - if k == "id": - continue - cfg[k] = v - - self._notify() - return cfg - - def delete_configuration(self, config_id: str) -> bool: - """Delete a configuration.""" - configs = self.get_configurations() - idx = -1 - for i, c in enumerate(configs): - if c.get("id") == config_id: - idx = i - break - if idx != -1: - configs.pop(idx) - if self._active_id == config_id: - self._active_id = None - self.project_data["components"] = copy.deepcopy(self._base_state["components"]) - self.project_data["parameters"] = copy.deepcopy(self._base_state["parameters"]) - self.project_data["assemblies"] = copy.deepcopy( - self._base_state.get("assemblies", []) - ) - self._notify() - return True - return False - - def get_overrides(self, config_id: str | None = None) -> dict[str, Any]: - """Get overrides dictionary for given or active configuration.""" - cid = config_id if config_id is not None else self._active_id - if cid is None: - return {} - cfg = self.get_configuration(cid) - if cfg is None: - return {} - return cfg.setdefault("parameter_overrides", {}) - - def set_override(self, config_id: str, path: str, value: Any) -> None: - """Set a parameter override.""" - cfg = self.get_configuration(config_id) - if cfg is None: - raise KeyError(f"Configuration '{config_id}' not found.") - overrides = cfg.setdefault("parameter_overrides", {}) - overrides[path] = value - self._notify() - - def remove_override(self, config_id: str, path: str) -> bool: - """Remove a parameter override.""" - cfg = self.get_configuration(config_id) - if cfg is None: - return False - overrides = cfg.setdefault("parameter_overrides", {}) - if path in overrides: - del overrides[path] - self._notify() - return True - return False - - def is_overridden(self, path: str, config_id: str | None = None) -> bool: - """Check if a path is overridden.""" - overrides = self.get_overrides(config_id) - return path in overrides - - def get_effective_project_parameters(self, config_id: str | None = None) -> dict[str, Any]: - """Compute resolved project parameters for the active configuration.""" - if config_id is not None and config_id != self._active_id: - cfg = self.get_configuration(config_id) - params = copy.deepcopy(self._base_state["parameters"]) - if cfg: - for path, val in cfg.get("parameter_overrides", {}).items(): - if path.startswith("project.parameters."): - k = path[len("project.parameters.") :] - if val is None: - params.pop(k, None) - else: - params[k] = copy.deepcopy(val) - else: - params = copy.deepcopy(self.project_data.get("parameters", {})) - - return self.resolver.resolve_all(params) - - def get_materialized_components(self, config_id: str | None = None) -> list[dict[str, Any]]: - """Return the materialized component list for any configuration without switching active state.""" - if config_id is None or config_id == self._active_id: - return self.project_data.get("components", []) - cfg = self.get_configuration(config_id) - if cfg is None: - return copy.deepcopy(self._base_state.get("components", [])) - comps, _, _ = apply_configuration_delta( - self._base_state.get("components", []), - self._base_state.get("parameters", {}), - self._base_state.get("assemblies", []), - cfg, - ) - return comps - - def get_resolved_component( - self, component: dict[str, Any], config_id: str | None = None - ) -> dict[str, Any]: - """Return a deep copy of a component with inline expressions evaluated.""" - comp_copy = copy.deepcopy(component) - effective_params = self.get_effective_project_parameters(config_id) - - if "parameters" in comp_copy and isinstance(comp_copy["parameters"], dict): - comp_copy["parameters"] = self.resolver.evaluate_component_parameters( - comp_copy["parameters"], effective_params - ) - # Apply evaluated transform expressions to comp_copy["transform"] - tf_exprs = comp_copy["parameters"].get("transform_expressions") - if isinstance(tf_exprs, dict): - tf = comp_copy.setdefault("transform", {}) - pos_dict = tf.setdefault("position", {}) - rot_dict = tf.setdefault("rotation", {}) - for k, v in tf_exprs.items(): - if isinstance(v, (int, float)): - if k.startswith("pos."): - pos_dict[k[4:]] = float(v) - elif k.startswith("position."): - pos_dict[k[9:]] = float(v) - elif k.startswith("rot."): - rot_dict[k[4:]] = float(v) - elif k.startswith("rotation."): - rot_dict[k[9:]] = float(v) - # Apply evaluated mass_expression to comp_copy["mass"] - mass_expr = comp_copy["parameters"].get("mass_expression") - if isinstance(mass_expr, (int, float)): - comp_copy["mass"] = float(mass_expr) - - return comp_copy diff --git a/src/setuav_studio/model/constraint.py b/src/setuav_studio/model/constraint.py index 12868c3..1c4ea9c 100644 --- a/src/setuav_studio/model/constraint.py +++ b/src/setuav_studio/model/constraint.py @@ -3,14 +3,42 @@ from __future__ import annotations import contextlib +import re from dataclasses import dataclass, field from typing import Any -from setuav_studio.model.configuration import get_by_path from setuav_studio.model.expression import ExpressionEvaluator from setuav_studio.model.parameter import ParameterResolver +def parse_path_segments(path: str) -> list[str | int]: + """Parse a dot/bracket notation path into list of key/index segments.""" + tokens = re.findall(r"[^.\[\]]+|\[\d+\]", path) + segments: list[str | int] = [] + for token in tokens: + if token.startswith("[") and token.endswith("]"): + segments.append(int(token[1:-1])) + else: + segments.append(token) + return segments + + +def get_by_path(target: Any, path: str) -> Any: + """Retrieve value from nested dict/list using dot/bracket path.""" + segments = parse_path_segments(path) + curr = target + for seg in segments: + if isinstance(seg, int): + if not isinstance(curr, (list, tuple)) or seg >= len(curr): + raise IndexError(f"Index {seg} out of bounds in path '{path}'") + curr = curr[seg] + else: + if not isinstance(curr, dict) or seg not in curr: + raise KeyError(f"Key '{seg}' not found in path '{path}'") + curr = curr[seg] + return curr + + @dataclass class ConstraintResult: """Outcome of evaluating a single constraint rule.""" diff --git a/src/setuav_studio/model/scope.py b/src/setuav_studio/model/scope.py index 452a66e..736c2be 100644 --- a/src/setuav_studio/model/scope.py +++ b/src/setuav_studio/model/scope.py @@ -254,16 +254,15 @@ def build_universal_scope( # noqa: C901 Single source of truth used for runtime expression evaluation and UI symbol discovery. """ from setuav_studio.model.component import GenericComponent - from setuav_studio.model.configuration import ConfigurationManager from setuav_studio.model.parameter import ParameterResolver resolver = ParameterResolver() - cfg_mgr = ConfigurationManager(project_data, resolver=resolver) + raw_params = project_data.get("parameters", {}) if isinstance(project_data, dict) else {} + resolved_params = resolver.resolve_all(raw_params) if isinstance(raw_params, dict) else {} scope: dict[str, Any] = {} # 1. Project Global Parameters & Constants - resolved_params = cfg_mgr.get_effective_project_parameters(config_id) for k, v in resolved_params.items(): clean_k = k.replace("-", "_") scope[k] = v @@ -271,7 +270,7 @@ def build_universal_scope( # noqa: C901 scope[clean_k] = v # 2. Materialized & Resolved Components - components = cfg_mgr.get_materialized_components(config_id) + components = project_data.get("components", []) if isinstance(project_data, dict) else [] models_list: list[Any] = [] total_mass = 0.0 @@ -279,7 +278,7 @@ def build_universal_scope( # noqa: C901 for comp in components: if not isinstance(comp, dict): continue - resolved_comp = cfg_mgr.get_resolved_component(comp, config_id) + resolved_comp = resolver.evaluate_component_parameters(comp, resolved_params) if api is not None and hasattr(api, "create_component_model"): model = api.create_component_model(resolved_comp) else: diff --git a/src/setuav_studio/model/vehicle.py b/src/setuav_studio/model/vehicle.py index 5ad0868..89d942d 100644 --- a/src/setuav_studio/model/vehicle.py +++ b/src/setuav_studio/model/vehicle.py @@ -19,7 +19,6 @@ class Vehicle: name: str = "Unnamed Vehicle" type: str = "org.setuav.core:vehicle" systems: list[System] = field(default_factory=list) - configurations: list[dict[str, Any]] = field(default_factory=list) states: list[State] = field(default_factory=list) parameters: Data = field(default_factory=Data) plugins: Data = field(default_factory=Data) @@ -78,7 +77,6 @@ def to_dict(self) -> dict[str, Any]: "name": self.name, "type": self.type, "systems": [s.to_dict() for s in self.systems], - "configurations": list(self.configurations), "states": [s.to_dict() for s in self.states], "parameters": self.parameters.to_dict(), "plugins": self.plugins.to_dict(), @@ -111,10 +109,7 @@ def from_dict(cls, data: dict[str, Any]) -> Vehicle: if isinstance(s, (dict, State)) ] - # 3. Parse Configurations - configs = list(data.get("configurations", [])) - - # 4. Plugins / Extensions storage + # 3. Plugins / Extensions storage plugins_data = data.get("plugins") or data.get("extensions") or {} return cls( @@ -122,7 +117,6 @@ def from_dict(cls, data: dict[str, Any]) -> Vehicle: name=str(data.get("name", "Unnamed Vehicle")), type=str(data.get("type", "org.setuav.core:vehicle")), systems=systems, - configurations=configs, states=states, parameters=Data.from_dict(data.get("parameters", {})), plugins=Data.from_dict(plugins_data if isinstance(plugins_data, dict) else {}), diff --git a/src/setuav_studio/project/document.py b/src/setuav_studio/project/document.py index 680a725..32dce8b 100644 --- a/src/setuav_studio/project/document.py +++ b/src/setuav_studio/project/document.py @@ -119,26 +119,17 @@ def set_component_plugin_data(self, comp_id: str, namespace: str, value: Any) -> comp["plugins"][namespace] = value self.modified = True - def get_configuration_manager(self) -> Any: - """Return the shared ConfigurationManager instance for this project.""" - if ( - not hasattr(self, "_config_manager") - or getattr(self, "_config_manager_data", None) is not self.data - ): - from setuav_studio.model.configuration import ConfigurationManager - - self._config_manager = ConfigurationManager(self.data) - self._config_manager_data = self.data - return self._config_manager - def get_component_models( self, api: Any | None = None, config_id: str | None = None ) -> list[Any]: """Return the list of typed domain model instances for all project components.""" from setuav_studio.model import GenericComponent + from setuav_studio.model.parameter import ParameterResolver - cfg_mgr = self.get_configuration_manager() - components = cfg_mgr.get_materialized_components(config_id) + resolver = ParameterResolver() + raw_params = self.data.get("parameters", {}) if isinstance(self.data, dict) else {} + resolved_params = resolver.resolve_all(raw_params) if isinstance(raw_params, dict) else {} + components = self.data.get("components", []) if isinstance(self.data, dict) else [] models: list[Any] = [] if not isinstance(components, list): return models @@ -146,7 +137,7 @@ def get_component_models( for comp in components: if not isinstance(comp, dict): continue - resolved_comp = cfg_mgr.get_resolved_component(comp, config_id) + resolved_comp = resolver.evaluate_component_parameters(comp, resolved_params) if api is not None and hasattr(api, "create_component_model"): model = api.create_component_model(resolved_comp) else: @@ -247,16 +238,6 @@ def save_project( logger.info("Saving project: %s", target) save_data = project.data - if hasattr(project, "get_configuration_manager"): - import copy - - cfg_mgr = project.get_configuration_manager() - cfg_mgr.sync_current_state_to_active() - if cfg_mgr.get_active_id() is not None: - save_data = copy.deepcopy(project.data) - save_data["components"] = copy.deepcopy(cfg_mgr._base_state["components"]) - save_data["parameters"] = copy.deepcopy(cfg_mgr._base_state["parameters"]) - save_data["assemblies"] = copy.deepcopy(cfg_mgr._base_state["assemblies"]) try: if target.suffix.lower() == ".suav": diff --git a/src/setuav_studio/ui/editor/parameter.py b/src/setuav_studio/ui/editor/parameter.py index 3247a19..7955ff9 100644 --- a/src/setuav_studio/ui/editor/parameter.py +++ b/src/setuav_studio/ui/editor/parameter.py @@ -14,7 +14,6 @@ QWidget, ) -from setuav_studio.model.configuration import ConfigurationManager from setuav_studio.model.parameter import ParameterResolver from setuav_studio.ui.icons import set_label_icon from setuav_studio.ui.widget.table import PropertyTableMixin @@ -166,10 +165,10 @@ def _load_data(self) -> None: ) self._set_property_value(self.general_table, "unit", active_sym, editable=False) - cfg_mgr = ConfigurationManager(data, self._resolver) + raw_params = data.get("parameters", {}) if isinstance(data, dict) else {} res_str = "—" with contextlib.suppress(Exception): - resolved = cfg_mgr.get_effective_project_parameters() + resolved = self._resolver.resolve_all(raw_params) res_val = resolved.get(self._param_key, "—") if isinstance(res_val, (int, float)): disp_val = ( diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py deleted file mode 100644 index d88c202..0000000 --- a/tests/core/test_configuration.py +++ /dev/null @@ -1,158 +0,0 @@ -import unittest - -from setuav_studio.model.configuration import ( - ConfigurationManager, - apply_configuration_delta, - compute_configuration_delta, - get_by_path, - parse_path_segments, - set_by_path, -) - - -class PathUtilsTests(unittest.TestCase): - def test_parse_path_segments(self) -> None: - path = "wing-1.parameters.geometry.profiles[0].chord" - segments = parse_path_segments(path) - self.assertEqual(segments, ["wing-1", "parameters", "geometry", "profiles", 0, "chord"]) - - def test_get_and_set_by_path(self) -> None: - data = { - "wing-1": { - "parameters": { - "geometry": { - "profiles": [{"chord": 200.0}], - "span": 1500.0, - } - } - } - } - val = get_by_path(data, "wing-1.parameters.geometry.profiles[0].chord") - self.assertEqual(val, 200.0) - - set_by_path(data, "wing-1.parameters.geometry.profiles[0].chord", 250.0) - self.assertEqual(get_by_path(data, "wing-1.parameters.geometry.profiles[0].chord"), 250.0) - - set_by_path(data, "wing-1.parameters.geometry.sweep", 15.0) - self.assertEqual(data["wing-1"]["parameters"]["geometry"]["sweep"], 15.0) - - -class DeltaConfigurationsTests(unittest.TestCase): - def setUp(self) -> None: - self.base_components = [ - { - "id": "fuselage-1", - "name": "Fuselage", - "type": "org.setuav.core:fuselage", - "parameters": {"mass": 500.0}, - }, - { - "id": "wing-1", - "name": "Main Wing", - "type": "org.setuav.core:lifting-surface", - "parameters": {"geometry": {"span": 2000.0, "chord": 250.0}}, - }, - ] - self.base_parameters = {"aspect_ratio": 8.0, "cruise_speed": 25.0} - - def test_apply_delta_with_overrides_and_additions(self) -> None: - cfg = { - "id": "vtol", - "name": "VTOL Config", - "tag": "VTOL", - "parameter_overrides": { - "project.parameters.aspect_ratio": 10.0, - "wing-1.parameters.geometry.span": 2600.0, - }, - "excluded_components": [], - "added_components": [ - {"id": "motor-vtol-1", "name": "VTOL Motor", "type": "org.setuav.core:motor"} - ], - "component_overrides": { - "wing-1": {"name": "Long Span Wing"}, - }, - } - comps, params, _ = apply_configuration_delta( - self.base_components, self.base_parameters, [], cfg - ) - - self.assertEqual(len(comps), 3) - self.assertEqual(comps[1]["name"], "Long Span Wing") - self.assertEqual(comps[1]["parameters"]["geometry"]["span"], 2600.0) - self.assertEqual(comps[2]["id"], "motor-vtol-1") - self.assertEqual(params["aspect_ratio"], 10.0) - self.assertEqual(params["cruise_speed"], 25.0) # inherited - - def test_compute_delta(self) -> None: - curr_components = [ - { - "id": "fuselage-1", - "name": "Fuselage", - "type": "org.setuav.core:fuselage", - "parameters": {"mass": 500.0}, - }, - { - "id": "wing-1", - "name": "High Speed Wing", - "type": "org.setuav.core:lifting-surface", - "parameters": {"geometry": {"span": 1800.0, "chord": 250.0}}, - }, - { - "id": "pod-1", - "name": "Camera Pod", - "type": "org.setuav.core:point-mass", - }, - ] - curr_parameters = {"aspect_ratio": 7.0, "cruise_speed": 35.0} - - delta = compute_configuration_delta( - self.base_components, self.base_parameters, curr_components, curr_parameters - ) - - self.assertEqual(delta["excluded_components"], []) - self.assertEqual(len(delta["added_components"]), 1) - self.assertEqual(delta["added_components"][0]["id"], "pod-1") - self.assertEqual(delta["component_overrides"]["wing-1"]["name"], "High Speed Wing") - self.assertEqual(delta["parameter_overrides"]["wing-1.parameters.geometry.span"], 1800.0) - self.assertEqual(delta["parameter_overrides"]["project.parameters.aspect_ratio"], 7.0) - self.assertEqual(delta["parameter_overrides"]["project.parameters.cruise_speed"], 35.0) - - def test_configuration_manager_lifecycle(self) -> None: - project_data = { - "components": self.base_components, - "parameters": self.base_parameters, - "configurations": [ - { - "id": "speed", - "name": "High Speed", - "tag": "SPD", - "parameter_overrides": { - "project.parameters.cruise_speed": 40.0, - }, - "excluded_components": [], - "added_components": [], - "component_overrides": { - "wing-1": {"name": "Clipped Wing"}, - }, - } - ], - } - manager = ConfigurationManager(project_data) - - # Initially in base - self.assertIsNone(manager.get_active_id()) - self.assertEqual(project_data["components"][1]["name"], "Main Wing") - - # Switch to speed config - manager.set_active_id("speed") - self.assertEqual(project_data["components"][1]["name"], "Clipped Wing") - self.assertEqual(project_data["parameters"]["cruise_speed"], 40.0) - - # Switch back to base - manager.set_active_id(None) - self.assertEqual(project_data["components"][1]["name"], "Main Wing") - self.assertEqual(project_data["parameters"]["cruise_speed"], 25.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/suites.py b/tests/suites.py index 125da94..15d1e18 100644 --- a/tests/suites.py +++ b/tests/suites.py @@ -15,7 +15,6 @@ "tests.core.test_atmosphere", "tests.core.test_command_palette", "tests.core.test_component_editor", - "tests.core.test_configuration", "tests.core.test_constraint", "tests.core.test_expression", "tests.core.test_main",