diff --git a/AGENTS.md b/AGENTS.md index bbe6d81..6b63da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,6 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`. - `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.** - `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service. - `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless. -- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`. - `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses. - Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5). diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index 3e00425..2b69352 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -342,17 +342,6 @@ class ModelCanvas(QtInteractor): # type: ignore[misc] self._renderer.set_show_local_axes(enabled) self.render() - def set_style(self, style: RenderStyle) -> None: - """Swap the visual style and repaint the scene. - - Applies the background immediately (it lives outside the renderer) - and rebuilds the actors whose colours come from the style LUTs. - """ - self._style = style - self.set_background(style.background_bottom, top=style.background_top) - self._renderer.set_style(style) - self.render() - def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: """Toggle viewport labels such as node and element names.""" self._renderer.set_display_options( diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py index 12c88cb..9eb9274 100644 --- a/src/otko/views/canvas3d/model_renderer.py +++ b/src/otko/views/canvas3d/model_renderer.py @@ -13,7 +13,7 @@ from __future__ import annotations import contextlib import enum from dataclasses import dataclass -from typing import Any +from typing import Any, ClassVar import numpy as np import pyvista as pv @@ -33,12 +33,7 @@ from otko.core import ( ZeroLengthElement, ZeroLengthSectionElement, ) -from otko.views.canvas3d.style import ( - SELECTED_STATE, - RenderStyle, - element_family_index, - family_palette, -) +from otko.views.canvas3d.style import RenderStyle class RendererMode(enum.Enum): @@ -190,6 +185,9 @@ class ModelRenderer: r, g, b = (int(round(x * 255)) for x in rgb) return f"#{r:02x}{g:02x}{b:02x}" + _NODE_LUT: ClassVar[list[str]] = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected + _FRAME_LUT: ClassVar[list[str]] = ["#338cd9", "#00ffff"] # blue normal, cyan selected + def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: self._plotter = plotter self._style = style or RenderStyle() @@ -208,8 +206,6 @@ class ModelRenderer: self._frame_actor: Any = None self._frame_ids_ordered: list[int] = [] self._frame_id_to_row: dict[int, int] = {} - #: Per-frame palette index (family), parallel to ``_frame_ids_ordered``. - self._frame_family: list[int] = [] self._node_label_actor: Any = None self._element_label_actor: Any = None self._show_node_labels: bool = False @@ -282,16 +278,6 @@ class ModelRenderer: if self._project is not None: self.render(self._project) - def set_style(self, style: RenderStyle) -> None: - """Swap the visual style and rebuild the scene it colours. - - Colours are baked into actor LUTs at build time, so a style change - needs a full re-render rather than a scalar-array tweak. - """ - self._style = style - if self._project is not None: - self.render(self._project) - def set_working_plane(self, plane: tuple[str, float] | None) -> None: """Update the working-plane filter and rebuild the grid overlay. @@ -320,12 +306,11 @@ class ModelRenderer: self._reglyph_nodes() if self._frame_pd is not None and self._frame_ids_ordered: - # Restore each cell to its family slot, then flag the selected ones. - states = np.array(self._frame_family, dtype=np.int8) + states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8) for eid in element_ids: row = self._frame_id_to_row.get(eid) if row is not None: - states[row] = SELECTED_STATE + states[row] = 1 self._frame_pd.cell_data["_oss_state"] = states self._frame_pd.Modified() @@ -394,7 +379,7 @@ class ModelRenderer: self._node_actor = self._plotter.add_mesh( glyph, scalars="_oss_state", - cmap=[self._style.node_color, self._style.node_selected_color], + cmap=self._NODE_LUT, clim=[0, 1], show_scalar_bar=False, pickable=True, @@ -407,7 +392,6 @@ class ModelRenderer: return cells: list[int] = [] ids: list[int] = [] - families: list[int] = [] for el in frames: try: i = self._node_id_to_row[el.nodes[0]] @@ -416,7 +400,6 @@ class ModelRenderer: continue cells.extend([2, i, j]) ids.append(el.id) - families.append(element_family_index(el)) if not ids: return pd = pv.PolyData() @@ -424,18 +407,16 @@ class ModelRenderer: pd.lines = np.array(cells, dtype=np.int64) pd.cell_data["_oss_id"] = np.array(ids, dtype=np.int64) pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object) - # Cell scalar carries the palette slot (family, or SELECTED_STATE). - pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8) + pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8) self._frame_pd = pd self._frame_ids_ordered = ids self._frame_id_to_row = {eid: i for i, eid in enumerate(ids)} - self._frame_family = families self._frame_actor = self._plotter.add_mesh( pd, scalars="_oss_state", - cmap=family_palette(self._style), - clim=[0, SELECTED_STATE], + cmap=self._FRAME_LUT, + clim=[0, 1], show_scalar_bar=False, line_width=3.0, pickable=True, @@ -780,8 +761,8 @@ class ModelRenderer: """Add one semi-transparent steel-blue non-pickable aux actor.""" actor = self._plotter.add_mesh( mesh, - color=self._style.extrusion_color, - opacity=self._style.extrusion_opacity, + color=(0.35, 0.60, 0.85), # cool steel-blue + opacity=0.22, show_edges=True, edge_color=(0.15, 0.25, 0.45), line_width=1.0, @@ -1001,7 +982,7 @@ class ModelRenderer: ndf = project.ndf dof_idx = _dof_indices(ndf) size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6) - support_color = self._style.support_color + support_color = (1.0, 0.5, 0.1) for node in project.nodes: if not any(node.restraint[i] for i in dof_idx): continue @@ -1015,7 +996,7 @@ class ModelRenderer: if not project.load_patterns or self._node_original_points is None: return scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6) - load_color = self._style.nodal_load_color + load_color = (0.2, 0.85, 0.2) node_by_id = {n.id: n for n in project.nodes} elem_by_id = {e.id: e for e in project.elements} for pattern in project.load_patterns: @@ -1046,7 +1027,7 @@ class ModelRenderer: # Draw N arrows along the element span, each perpendicular # to the axis in the direction of the load. Uses the same # orange-green palette as nodal loads but with shorter arrows. - elem_load_color = self._style.element_load_color # distributed loads + elem_load_color = (1.0, 0.55, 0.2) # orange n_arrows_per_elem = 5 for eload in pattern.element_loads: if not isinstance(eload, UniformElementLoad): @@ -1135,7 +1116,6 @@ class ModelRenderer: self._node_ids_ordered = [] self._node_id_to_row = {} self._frame_ids_ordered = [] - self._frame_family = [] self._frame_id_to_row = {} self._node_original_points = None self._deformation = None @@ -1168,7 +1148,7 @@ class ModelRenderer: return self._plotter.add_point_labels( pts, labels, - font_size=self._style.label_font_size, + font_size=12, shape_opacity=0.15, text_color="black", point_color="white", @@ -1198,7 +1178,7 @@ class ModelRenderer: return self._plotter.add_point_labels( np.asarray(centers), labels, - font_size=self._style.label_font_size, + font_size=12, shape_opacity=0.15, text_color="black", point_color="white", diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index 6051fd9..3e1de73 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -4,63 +4,11 @@ All colors, sizes, and glyph parameters live here. Changes apply globally through the renderer; downstream code never hard-codes a color or radius. A future "theme" feature can simply swap a different RenderStyle instance. - -The subset in :attr:`RenderStyle.EDITABLE` is surfaced to the user through -Options → Plot Properties… and persisted in ``QSettings`` (``plot/props``). -Palette values marked "opstool" are adapted from opstool v1.0.26 (GPL-3.0); -see ``NOTICE``. """ from __future__ import annotations -from dataclasses import dataclass, replace -from typing import Any, ClassVar - -from otko.core import CorotTrussElement, TrussElement, ZeroLengthElement - -#: Selected elements / nodes occupy the last slot of the palette LUT. -SELECTED_STATE: int = 3 - - -def element_family_index(element: object) -> int: - """Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length.""" - if isinstance(element, TrussElement | CorotTrussElement): - return 1 - if isinstance(element, ZeroLengthElement): - return 2 - return 0 - - -def family_palette(style: RenderStyle) -> list[str]: - """LUT shared by both backends: [beam, truss, link, selected].""" - return [ - style.element_beam_color, - style.element_truss_color, - style.element_link_color, - style.selected_color, - ] - - -#: Colours / sizes the Plot Properties dialog exposes, in display order. -STYLE_FIELDS: tuple[tuple[str, str, str], ...] = ( - ("element_beam_color", "Beam elements", "color"), - ("element_truss_color", "Truss elements", "color"), - ("element_link_color", "Link / zero-length", "color"), - ("selected_color", "Selection highlight", "color"), - ("node_color", "Nodes", "color"), - ("node_selected_color", "Selected nodes", "color"), - ("support_color", "Supports", "color"), - ("nodal_load_color", "Nodal loads", "color"), - ("element_load_color", "Element loads", "color"), - ("extrusion_color", "Section extrusions", "color"), - ("extrusion_opacity", "Extrusion opacity", "float"), - ("background_top", "Background (top)", "color"), - ("background_bottom", "Background (bottom)", "color"), - ("label_font_size", "Label font size", "int"), -) - -#: Field names the dialog edits and that are persisted between sessions. -EDITABLE_FIELDS: tuple[str, ...] = tuple(name for name, _label, _kind in STYLE_FIELDS) +from dataclasses import dataclass @dataclass(frozen=True, slots=True) @@ -71,13 +19,13 @@ class RenderStyle: background_top: str = "#dbe2ef" background_bottom: str = "#f5f7fb" - node_color: str = "#d9d9d9" # normal node sphere / marker - node_selected_color: str = "#00ffff" # selected node + node_color: str = "#f0a500" # warm gold + node_selected_color: str = "#00d4ff" # bright cyan frame_color: str = "#1f1f1f" - truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad + truss_color: str = "#2e5cb8" zerolength_color: str = "#a020f0" - selected_color: str = "#00ffff" # selected element highlight + selected_color: str = "#00d4ff" # ── element palette (adapted from opstool v1.0.26, GPL-3.0) ─────── # opstool colours wireframe elements by *family* rather than painting @@ -88,14 +36,6 @@ class RenderStyle: element_truss_color: str = "#FF8C00" # truss family element_link_color: str = "#39FF14" # zero-length / link family - # ── overlay colours ───────────────────────────────────────────── - support_color: str = "#ff8019" - nodal_load_color: str = "#33d933" - element_load_color: str = "#ff8c33" - extrusion_color: str = "#598cff" - extrusion_opacity: float = 0.22 - label_font_size: int = 12 - #: Diverging scale for scalar response overlays (force diagrams today, #: nodal / element response plots later): blue → red, evenly spaced. #: This is opstool's ``default_cmap`` (RdYlBu reversed). @@ -117,7 +57,7 @@ class RenderStyle: roller_color: str = "#e67e22" custom_support_color: str = "#7f8c8d" - load_color: str = "#27ae60" # Y-axis colour of the view triad + load_color: str = "#27ae60" mass_color: str = "#9b59b6" # ── sizes (relative to bbox diagonal unless absolute) ──────────── @@ -135,9 +75,6 @@ class RenderStyle: selection_thickness_factor: float = 1.6 # multiplier for selected actors - #: Names of the fields the Plot Properties dialog edits. - EDITABLE: ClassVar[tuple[str, ...]] = EDITABLE_FIELDS - # ── helpers ────────────────────────────────────────────────────── def response_colorscale(self) -> list[tuple[float, str]]: """Plotly ``colorscale`` form of :attr:`response_scale_colors`.""" @@ -147,11 +84,3 @@ class RenderStyle: return [ (index / (count - 1), color) for index, color in enumerate(self.response_scale_colors) ] - - def with_updates(self, **changes: Any) -> RenderStyle: - """Return a copy with ``changes`` applied (frozen-dataclass safe).""" - return replace(self, **changes) - - def editable_values(self) -> dict[str, Any]: - """The dialog-managed subset of this style, as a plain dict.""" - return {name: getattr(self, name) for name in self.EDITABLE} diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index 1e771d5..e5af025 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -271,12 +271,6 @@ class PlotlyCanvas(QWidget): self._options = replace(self._options, show_local_axes=bool(enabled)) self.render() - def set_style(self, style: RenderStyle) -> None: - """Swap the visual style and repaint the figure.""" - self._style = style - self._builder.set_style(style) - self.render() - def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: self._options = replace( self._options, diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 40ae710..8c1538b 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -44,11 +44,7 @@ from otko.core import ( ZeroLengthElement, ZeroLengthSectionElement, ) -from otko.views.canvas3d.style import ( - RenderStyle, - element_family_index, - family_palette, -) +from otko.views.canvas3d.style import RenderStyle _FRAME_CLASSES = ( ElasticBeamColumn, @@ -60,6 +56,19 @@ _FRAME_CLASSES = ( ZeroLengthElement, ) +#: Element families that get their own palette entry (see ``RenderStyle``). +_TRUSS_FAMILY = (TrussElement, CorotTrussElement) + + +def _family_index(el: Any) -> int: + """Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length.""" + if isinstance(el, _TRUSS_FAMILY): + return 1 + if isinstance(el, ZeroLengthElement): + return 2 + return 0 + + #: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`. _BOX_TRIS = ( (0, 1, 2), @@ -87,6 +96,7 @@ _SUPPORT_SYMBOLS = { _NODE_MARKER_SIZE = 7.0 _SUPPORT_MARKER_SIZE = 11.0 _SNAP_MARKER_SIZE = 8.0 +_LABEL_FONT_SIZE = 11 @dataclass(frozen=True) @@ -265,10 +275,6 @@ class PlotlyTraceBuilder: def __init__(self, style: RenderStyle | None = None) -> None: self._style = style or RenderStyle() - def set_style(self, style: RenderStyle) -> None: - """Swap the visual style; the next :meth:`build` picks it up.""" - self._style = style - # ── public ─────────────────────────────────────────────────────── def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene: opts = options or SceneOptions() @@ -482,12 +488,7 @@ class PlotlyTraceBuilder: ) -> None: if not len(points): return - colors = [ - self._style.node_selected_color - if nid in opts.selection_nodes - else self._style.node_color - for nid in node_ids - ] + colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids] data.append( { "type": "scatter3d", @@ -527,7 +528,12 @@ class PlotlyTraceBuilder: """ if not len(points): return - scale_colors = family_palette(self._style) + scale_colors = [ + self._style.element_beam_color, + self._style.element_truss_color, + self._style.element_link_color, + self._style.selected_color, + ] selected_index = len(scale_colors) - 1 x: list[float | None] = [] @@ -542,9 +548,7 @@ class PlotlyTraceBuilder: j = node_row.get(el.nodes[1]) if i is None or j is None: continue - index = float( - selected_index if el.id in opts.selection_elements else element_family_index(el) - ) + index = float(selected_index if el.id in opts.selection_elements else _family_index(el)) x.extend([float(points[i][0]), float(points[j][0]), None]) y.extend([float(points[i][1]), float(points[j][1]), None]) z.extend([float(points[i][2]), float(points[j][2]), None]) @@ -599,7 +603,7 @@ class PlotlyTraceBuilder: "y": [float(n.coords[1]) for n in nodes], "z": [float(n.coords[2]) for n in nodes], "marker": { - "color": self._style.support_color, + "color": "#ff8019", "size": _SUPPORT_MARKER_SIZE, "symbol": _SUPPORT_SYMBOLS[kind], "line": {"color": "#7f3f00", "width": 1}, @@ -704,7 +708,7 @@ class PlotlyTraceBuilder: nodal_u, nodal_v, nodal_w, - color=self._style.nodal_load_color, + color="#33d933", name="nodal-loads", size=scale, ) @@ -718,7 +722,7 @@ class PlotlyTraceBuilder: dist_u, dist_v, dist_w, - color=self._style.element_load_color, + color="#ff8c33", name="element-loads", size=0.6 * scale, ) @@ -829,13 +833,7 @@ class PlotlyTraceBuilder: y_local, z_local = _frame_basis(el, x_local) mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z)) if not mesh.is_empty: - data.append( - mesh.as_trace( - color=self._style.extrusion_color, - opacity=self._style.extrusion_opacity, - name="extrusions", - ) - ) + data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions")) @staticmethod def _box_corners( @@ -874,11 +872,7 @@ class PlotlyTraceBuilder: ) -> None: if opts.show_node_labels and len(points): labels = [(n.name.strip() if n.name.strip() else f"N{n.id}") for n in project.nodes] - data.append( - _text_trace( - points, labels, name="node-labels", font_size=self._style.label_font_size - ) - ) + data.append(_text_trace(points, labels, name="node-labels")) if opts.show_element_labels and len(points): centers: list[np.ndarray] = [] labels: list[str] = [] @@ -892,14 +886,7 @@ class PlotlyTraceBuilder: centers.append((points[i] + points[j]) / 2.0) labels.append(el.name.strip() if el.name.strip() else f"E{el.id}") if centers: - data.append( - _text_trace( - np.asarray(centers), - labels, - name="element-labels", - font_size=self._style.label_font_size, - ) - ) + data.append(_text_trace(np.asarray(centers), labels, name="element-labels")) @staticmethod def _build_hover_marker(data: list[dict[str, Any]]) -> int: @@ -955,9 +942,7 @@ def _cone_trace( } -def _text_trace( - points: np.ndarray, labels: list[str], *, name: str, font_size: int -) -> dict[str, Any]: +def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]: return { "type": "scatter3d", "mode": "text", @@ -966,7 +951,7 @@ def _text_trace( "z": [float(p[2]) for p in points], "text": labels, "textposition": "top center", - "textfont": {"size": font_size, "color": "#111111"}, + "textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"}, "hoverinfo": "skip", "name": name, "showlegend": False, diff --git a/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py index e4dff02..d2a3b45 100644 --- a/src/otko/views/dialogs/__init__.py +++ b/src/otko/views/dialogs/__init__.py @@ -34,7 +34,6 @@ from otko.views.dialogs.move import MoveDialog from otko.views.dialogs.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.pattern_loads import PatternLoadsDialog from otko.views.dialogs.plain_pattern import PlainPatternDialog -from otko.views.dialogs.plot_properties import PlotPropertiesDialog from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog from otko.views.dialogs.quick_guide import QuickGuideDialog from otko.views.dialogs.replicate import ReplicateDialog @@ -70,7 +69,6 @@ __all__ = [ "PathTimeSeriesDialog", "PatternLoadsDialog", "PlainPatternDialog", - "PlotPropertiesDialog", "QuickGridLinesDialog", "QuickGuideDialog", "ReplicateDialog", diff --git a/src/otko/views/dialogs/plot_properties.py b/src/otko/views/dialogs/plot_properties.py deleted file mode 100644 index 5240546..0000000 --- a/src/otko/views/dialogs/plot_properties.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`. - -The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``), -so adding an editable property is a one-line change there plus a default on -the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed` -so the canvas can preview them live; ``style()`` returns the composed result. -""" - -from __future__ import annotations - -from PySide6.QtCore import Signal -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QColorDialog, - QDialog, - QDialogButtonBox, - QDoubleSpinBox, - QFormLayout, - QLabel, - QPushButton, - QSpinBox, - QVBoxLayout, - QWidget, -) - -from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle - - -class _ColorButton(QPushButton): - """A swatch that opens :class:`QColorDialog` and reports the hex value.""" - - colorChanged = Signal(str) - - def __init__(self, color: str, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._color = color - self.setFixedWidth(96) - self._refresh() - self.clicked.connect(self._pick) - - def color(self) -> str: - """Current colour as a ``#rrggbb`` string.""" - return self._color - - def set_color(self, color: str) -> None: - """Set the swatch colour (does not emit :attr:`colorChanged`).""" - self._color = color - self._refresh() - - def _refresh(self) -> None: - self.setText(self._color.upper()) - self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};") - - def _text_color(self) -> str: - color = QColor(self._color) - luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue() - return "#000000" if luminance > 140 else "#ffffff" - - def _pick(self) -> None: - chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour") - if chosen.isValid(): - self.set_color(chosen.name()) - self.colorChanged.emit(self._color) - - -class PlotPropertiesDialog(QDialog): - """Modeless-free editor for the persisted plot style.""" - - #: Emitted with the composed :class:`RenderStyle` on every edit. - changed = Signal(object) - - def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setWindowTitle("Plot Properties") - self._base = style - self._colors: dict[str, _ColorButton] = {} - self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {} - - layout = QVBoxLayout(self) - form = QFormLayout() - for name, label, kind in STYLE_FIELDS: - widget = self._build_widget(name, getattr(style, name), kind) - form.addRow(QLabel(f"{label}:"), widget) - layout.addLayout(form) - - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - ) - reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole) - assert reset is not None - reset.clicked.connect(self._reset) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - # ── public ────────────────────────────────────────────────────── - def plot_style(self) -> RenderStyle: - """The composed style for the current widget values. - - Named ``plot_style`` (not ``style``) because :meth:`QWidget.style` - already exists and must keep its Qt meaning. - """ - updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()} - updates.update({name: w.value() for name, w in self._numbers.items()}) - return self._base.with_updates(**updates) - - # ── internals ─────────────────────────────────────────────────── - def _build_widget(self, name: str, value: object, kind: str) -> QWidget: - if kind == "color": - button = _ColorButton(str(value)) - button.colorChanged.connect(self._emit_changed) - self._colors[name] = button - return button - if kind == "float": - spin = QDoubleSpinBox() - spin.setRange(0.0, 1.0) - spin.setSingleStep(0.05) - spin.setDecimals(2) - spin.setValue(float(value)) # type: ignore[arg-type] - spin.valueChanged.connect(self._emit_changed) - self._numbers[name] = spin - return spin - int_spin = QSpinBox() - int_spin.setRange(6, 32) - int_spin.setValue(int(value)) # type: ignore[call-overload] - int_spin.valueChanged.connect(self._emit_changed) - self._numbers[name] = int_spin - return int_spin - - def _emit_changed(self, *_args: object) -> None: - self.changed.emit(self.plot_style()) - - def _reset(self) -> None: - defaults = RenderStyle() - for name, button in self._colors.items(): - button.set_color(getattr(defaults, name)) - for name, spin in self._numbers.items(): - spin.setValue(getattr(defaults, name)) - self._emit_changed() diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index cca1d7e..cbd21e3 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -13,7 +13,6 @@ The ``projectChanged`` / ``modelMutated`` signals live on the from __future__ import annotations -import json from pathlib import Path from typing import Any @@ -26,7 +25,6 @@ from otko.views.action_handlers import ActionHandlers from otko.views.canvas3d import ModelCanvas from otko.views.canvas3d.diagram_renderer import DiagramRenderer from otko.views.canvas3d.selection import SelectionState -from otko.views.canvas3d.style import RenderStyle from otko.views.canvas_base import plotly_available from otko.views.dock_manager import DockManager from otko.views.menu_builder import MenuBuilder @@ -74,8 +72,6 @@ class MainWindow( self._settings = settings if settings is not None else QSettings("OTKO", "OTKO") self._canvas_backend = self._stored_canvas_backend() - #: Shared, user-editable visual style (both backends read it). - self._style = self._load_plot_style() self._canvases: dict[str, Any] = {} self._diagram_renderers: dict[str, DiagramRenderer] = {} # A stack holds every backend's canvas: swapping is just @@ -119,9 +115,9 @@ class MainWindow( # Imported lazily so a plotly-less install still boots PyVista. from otko.views.canvas_plotly import PlotlyCanvas - canvas = PlotlyCanvas(self, style=self._style, selection=self._selection) + canvas = PlotlyCanvas(self, selection=self._selection) else: - canvas = ModelCanvas(self, style=self._style, selection=self._selection) + canvas = ModelCanvas(self, selection=self._selection) self._canvases[backend] = canvas return canvas @@ -149,35 +145,6 @@ class MainWindow( """Name of the active canvas backend (``"pyvista"`` or ``"plotly"``).""" return self._canvas_backend - # ── plot style ─────────────────────────────────────────────────── - def _load_plot_style(self) -> RenderStyle: - """Style from the last session, ignoring anything unrecognised.""" - raw = self._settings.value("plot/props") - if not raw: - return RenderStyle() - try: - data = json.loads(str(raw)) - except ValueError: - return RenderStyle() - if not isinstance(data, dict): - return RenderStyle() - allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE} - try: - return RenderStyle(**allowed) - except (TypeError, ValueError): - return RenderStyle() - - def _save_plot_style(self) -> None: - self._settings.setValue("plot/props", json.dumps(self._style.editable_values())) - - def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None: - """Apply a style to every canvas (live preview when ``persist`` is off).""" - self._style = style - for canvas in self._canvases.values(): - canvas.set_style(style) - if persist: - self._save_plot_style() - def _build_tooling(self) -> None: """(Re)build the tool controller and tools against the current canvas.""" self._tool_controller = ToolController(self._canvas, self._vm, self) @@ -294,7 +261,6 @@ class MainWindow( self._act_about.triggered.connect(self._on_about) self._act_quick_guide.triggered.connect(self._on_quick_guide) self._act_set_units.triggered.connect(self._on_set_units) - self._act_plot_properties.triggered.connect(self._on_plot_properties) # Edit self._act_delete.triggered.connect(self._on_delete) diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index 5e7a271..0aa0b2e 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -192,10 +192,6 @@ class MenuBuilder: # No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run. self._act_quick_guide = QAction("&Quick Guide", self) self._act_set_units = QAction("Set Display &Units…", self) - self._act_plot_properties = QAction("&Plot Properties…", self) - self._act_plot_properties.setToolTip( - "Colours and sizes used by both canvas backends (persisted)." - ) # Canvas backend — exclusive radio pair live-switching the 3D view. from otko.views.canvas_base import plotly_available @@ -516,7 +512,6 @@ class MenuBuilder: m_options = mb.addMenu("&Options") m_options.addAction(self._act_set_units) - m_options.addAction(self._act_plot_properties) m_backend = m_options.addMenu("Canvas &Backend") m_backend.addAction(self._act_canvas_pyvista) m_backend.addAction(self._act_canvas_plotly) diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index 06db479..63d1f0f 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -50,23 +50,6 @@ class RenderControls: ) # ── SAP2000-style view switching + working level ─────────────── - def _on_plot_properties(self) -> None: - """Edit the shared visual style. - - Edits preview live on both canvases; Cancel restores the style the - dialog opened with, and only OK persists to ``QSettings``. - """ - from otko.views.dialogs import PlotPropertiesDialog - - original = self._style - dialog = PlotPropertiesDialog(original, self) - dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False)) - if dialog.exec() == QDialog.DialogCode.Accepted: - self.set_plot_style(dialog.plot_style()) - self._log("Plot properties updated.") - else: - self.set_plot_style(original, persist=False) - def _on_toggle_parallel(self, on: bool) -> None: cam = self._canvas.camera cam.parallel_projection = on diff --git a/tests/gui/test_plot_properties.py b/tests/gui/test_plot_properties.py deleted file mode 100644 index 3ef3601..0000000 --- a/tests/gui/test_plot_properties.py +++ /dev/null @@ -1,131 +0,0 @@ -"""GUI tests for Options → Plot Properties. - -Covers the dialog itself plus MainWindow's live-apply / persist / revert -wiring. QSettings are injected so nothing touches real user state. -""" - -from __future__ import annotations - -import pytest - -pytest.importorskip("PySide6") -pytest.importorskip("pyvistaqt") - -from PySide6.QtCore import QObject, QSettings, Signal -from PySide6.QtWidgets import QDialog - - -def _settings(tmp_path): # type: ignore[no-untyped-def] - return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat) - - -def _window(tmp_path, qtbot): # type: ignore[no-untyped-def] - from otko.views.main_window import MainWindow - - window = MainWindow(settings=_settings(tmp_path)) - qtbot.addWidget(window) - return window - - -def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - from otko.views.canvas3d.style import RenderStyle - - window = _window(tmp_path, qtbot) - assert window._style == RenderStyle() - - -def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - window.swap_canvas_backend("plotly") # build the second canvas too - - window.set_plot_style( - window._style.with_updates(element_beam_color="#ff0000", label_font_size=20) - ) - - assert set(window._canvases) == {"pyvista", "plotly"} - for canvas in window._canvases.values(): - assert canvas._style.element_beam_color == "#ff0000" - assert canvas._style.label_font_size == 20 - - # A fresh window sharing the settings file reloads the customisation. - from otko.views.main_window import MainWindow - - fresh = MainWindow(settings=_settings(tmp_path)) - qtbot.addWidget(fresh) - assert fresh._style.element_beam_color == "#ff0000" - assert fresh._style.label_font_size == 20 - - -def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False) - - assert window._canvas._style.node_color == "#010203" - assert window._settings.value("plot/props") is None - - -def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - original = window._style - preview = original.with_updates(node_color="#010203") - - class _RejectingDialog(QObject): - changed = Signal(object) - - def __init__(self, style: object, parent: QObject | None = None) -> None: - super().__init__(parent) - self._style = preview - - def exec(self) -> QDialog.DialogCode: - self.changed.emit(preview) # live preview happens... - return QDialog.DialogCode.Rejected # ...but the user cancels - - def plot_style(self): # type: ignore[no-untyped-def] - return self._style - - monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog) - window._on_plot_properties() - - assert window._style == original - assert window._canvas._style.node_color == original.node_color - assert window._settings.value("plot/props") is None - - -def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - preview = window._style.with_updates(node_color="#010203") - - class _AcceptingDialog(QObject): - changed = Signal(object) - - def __init__(self, style: object, parent: QObject | None = None) -> None: - super().__init__(parent) - self._style = preview - - def exec(self) -> QDialog.DialogCode: - return QDialog.DialogCode.Accepted - - def plot_style(self): # type: ignore[no-untyped-def] - return self._style - - monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog) - window._on_plot_properties() - - assert window._style.node_color == "#010203" - assert window._canvas._style.node_color == "#010203" - assert "#010203" in str(window._settings.value("plot/props")) - - -def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - from otko.views.canvas3d.style import RenderStyle - from otko.views.dialogs import PlotPropertiesDialog - - dialog = PlotPropertiesDialog(RenderStyle()) - qtbot.addWidget(dialog) - assert dialog.plot_style() == RenderStyle() - - dialog._colors["node_color"].set_color("#abcdef") - assert dialog.plot_style().node_color == "#abcdef" - - dialog._reset() - assert dialog.plot_style() == RenderStyle() diff --git a/tests/unit/test_render_style.py b/tests/unit/test_render_style.py deleted file mode 100644 index 32b027b..0000000 --- a/tests/unit/test_render_style.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Unit tests for the shared render style and its editable subset.""" - -from __future__ import annotations - -import dataclasses - -import pytest - -from otko.core import ElasticBeamColumn, Node, Project, TrussElement, ZeroLengthElement -from otko.views.canvas3d.style import ( - EDITABLE_FIELDS, - SELECTED_STATE, - STYLE_FIELDS, - RenderStyle, - element_family_index, - family_palette, -) - - -def test_defaults_are_the_documented_palette() -> None: - style = RenderStyle() - assert style.node_color == "#d9d9d9" - assert style.selected_color == "#00ffff" - # opstool-derived element palette. - assert style.element_beam_color == "#0652ff" - assert style.element_truss_color == "#FF8C00" - assert style.element_link_color == "#39FF14" - assert len(style.response_scale_colors) == 10 - assert style.response_scale_colors[0] == "#313695" # cold end - assert style.response_scale_colors[-1] == "#a50026" # warm end - - -def test_with_updates_is_immutable() -> None: - original = RenderStyle() - updated = original.with_updates(node_color="#123456", label_font_size=20) - assert updated is not original - assert updated.node_color == "#123456" - assert updated.label_font_size == 20 - # The source instance is untouched (frozen dataclass contract). - assert original.node_color == "#d9d9d9" - assert original.label_font_size == 12 - - -def test_editable_subset_matches_the_field_table() -> None: - style = RenderStyle() - assert RenderStyle.EDITABLE == EDITABLE_FIELDS - assert set(style.editable_values()) == set(EDITABLE_FIELDS) - # Every advertised field must exist on the dataclass with the right kind. - kinds = {name: kind for name, _label, kind in STYLE_FIELDS} - for name in EDITABLE_FIELDS: - assert hasattr(style, name), name - assert kinds[name] in {"color", "float", "int"} - - -def test_response_colorscale_is_an_evenly_spaced_mapping() -> None: - style = RenderStyle() - scale = style.response_colorscale() - assert len(scale) == len(style.response_scale_colors) - assert scale[0] == (0.0, style.response_scale_colors[0]) - assert scale[-1] == (1.0, style.response_scale_colors[-1]) - positions = [position for position, _color in scale] - assert positions == sorted(positions) - - -@pytest.mark.parametrize( - ("element", "expected"), - [ - (ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), 0), - (TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), 1), - (ZeroLengthElement(id=3, nodes=(1, 1), material_ids=(1,), dofs=(1,)), 2), - ], -) -def test_element_family_index(element: object, expected: int) -> None: - assert element_family_index(element) == expected - - -def test_family_palette_order_and_selected_slot() -> None: - style = RenderStyle(node_color="#010101") - palette = family_palette(style) - assert palette == [ - style.element_beam_color, - style.element_truss_color, - style.element_link_color, - style.selected_color, - ] - assert len(palette) - 1 == SELECTED_STATE - - -def test_editable_values_round_trip_through_replace() -> None: - style = RenderStyle() - values = style.editable_values() - assert RenderStyle(**values) == style - # And the persisted shape is JSON-friendly (str/int/float only). - import json - - assert json.loads(json.dumps(values)) == values - - -def test_style_is_a_frozen_slots_dataclass() -> None: - assert dataclasses.is_dataclass(RenderStyle) - with pytest.raises(dataclasses.FrozenInstanceError): - RenderStyle().node_color = "#000000" # type: ignore[misc] - - -def test_project_builder_helper_is_unused_but_valid() -> None: - """A style constructed from a project's elements classifies every family.""" - project = Project( - ndm=2, - ndf=3, - nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))], - elements=[ - ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), - TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), - ], - ) - indices = [element_family_index(el) for el in project.elements] - assert indices == [0, 1] diff --git a/tests/unit/test_renderer.py b/tests/unit/test_renderer.py index 04529d6..1586ca2 100644 --- a/tests/unit/test_renderer.py +++ b/tests/unit/test_renderer.py @@ -24,7 +24,6 @@ from otko.views.canvas3d.model_renderer import ( RendererMode, _classify_support, ) -from otko.views.canvas3d.style import SELECTED_STATE # ──────────────────────────── support classification ──────────────────────────── @@ -126,13 +125,9 @@ def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist() # Nodes 1 and 3 selected → row 0 and row 2 assert node_states == [1, 0, 1] - # Frame cells carry the palette slot: the element family normally, and - # SELECTED_STATE when selected. + # Element 2 selected → it's the second frame (index 1 in frame_ids_ordered) selected_frame_idx = r._frame_id_to_row[2] - assert frame_states[selected_frame_idx] == SELECTED_STATE - for index, family in enumerate(r._frame_family): - expected = SELECTED_STATE if index == selected_frame_idx else family - assert frame_states[index] == expected + assert frame_states[selected_frame_idx] == 1 def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]