Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd365be77d | |||
| 56f1f6c08b |
14 changed files with 633 additions and 59 deletions
|
|
@ -20,6 +20,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
|
||||||
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
|
- `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/`: 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/` (**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.
|
- `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).
|
- 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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,17 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
||||||
self._renderer.set_show_local_axes(enabled)
|
self._renderer.set_show_local_axes(enabled)
|
||||||
self.render()
|
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:
|
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||||
"""Toggle viewport labels such as node and element names."""
|
"""Toggle viewport labels such as node and element names."""
|
||||||
self._renderer.set_display_options(
|
self._renderer.set_display_options(
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from __future__ import annotations
|
||||||
import contextlib
|
import contextlib
|
||||||
import enum
|
import enum
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, ClassVar
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyvista as pv
|
import pyvista as pv
|
||||||
|
|
@ -33,7 +33,12 @@ from otko.core import (
|
||||||
ZeroLengthElement,
|
ZeroLengthElement,
|
||||||
ZeroLengthSectionElement,
|
ZeroLengthSectionElement,
|
||||||
)
|
)
|
||||||
from otko.views.canvas3d.style import RenderStyle
|
from otko.views.canvas3d.style import (
|
||||||
|
SELECTED_STATE,
|
||||||
|
RenderStyle,
|
||||||
|
element_family_index,
|
||||||
|
family_palette,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RendererMode(enum.Enum):
|
class RendererMode(enum.Enum):
|
||||||
|
|
@ -185,9 +190,6 @@ class ModelRenderer:
|
||||||
r, g, b = (int(round(x * 255)) for x in rgb)
|
r, g, b = (int(round(x * 255)) for x in rgb)
|
||||||
return f"#{r:02x}{g:02x}{b:02x}"
|
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:
|
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
|
||||||
self._plotter = plotter
|
self._plotter = plotter
|
||||||
self._style = style or RenderStyle()
|
self._style = style or RenderStyle()
|
||||||
|
|
@ -206,6 +208,8 @@ class ModelRenderer:
|
||||||
self._frame_actor: Any = None
|
self._frame_actor: Any = None
|
||||||
self._frame_ids_ordered: list[int] = []
|
self._frame_ids_ordered: list[int] = []
|
||||||
self._frame_id_to_row: dict[int, 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._node_label_actor: Any = None
|
||||||
self._element_label_actor: Any = None
|
self._element_label_actor: Any = None
|
||||||
self._show_node_labels: bool = False
|
self._show_node_labels: bool = False
|
||||||
|
|
@ -278,6 +282,16 @@ class ModelRenderer:
|
||||||
if self._project is not None:
|
if self._project is not None:
|
||||||
self.render(self._project)
|
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:
|
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
|
||||||
"""Update the working-plane filter and rebuild the grid overlay.
|
"""Update the working-plane filter and rebuild the grid overlay.
|
||||||
|
|
||||||
|
|
@ -306,11 +320,12 @@ class ModelRenderer:
|
||||||
self._reglyph_nodes()
|
self._reglyph_nodes()
|
||||||
|
|
||||||
if self._frame_pd is not None and self._frame_ids_ordered:
|
if self._frame_pd is not None and self._frame_ids_ordered:
|
||||||
states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8)
|
# Restore each cell to its family slot, then flag the selected ones.
|
||||||
|
states = np.array(self._frame_family, dtype=np.int8)
|
||||||
for eid in element_ids:
|
for eid in element_ids:
|
||||||
row = self._frame_id_to_row.get(eid)
|
row = self._frame_id_to_row.get(eid)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
states[row] = 1
|
states[row] = SELECTED_STATE
|
||||||
self._frame_pd.cell_data["_oss_state"] = states
|
self._frame_pd.cell_data["_oss_state"] = states
|
||||||
self._frame_pd.Modified()
|
self._frame_pd.Modified()
|
||||||
|
|
||||||
|
|
@ -379,7 +394,7 @@ class ModelRenderer:
|
||||||
self._node_actor = self._plotter.add_mesh(
|
self._node_actor = self._plotter.add_mesh(
|
||||||
glyph,
|
glyph,
|
||||||
scalars="_oss_state",
|
scalars="_oss_state",
|
||||||
cmap=self._NODE_LUT,
|
cmap=[self._style.node_color, self._style.node_selected_color],
|
||||||
clim=[0, 1],
|
clim=[0, 1],
|
||||||
show_scalar_bar=False,
|
show_scalar_bar=False,
|
||||||
pickable=True,
|
pickable=True,
|
||||||
|
|
@ -392,6 +407,7 @@ class ModelRenderer:
|
||||||
return
|
return
|
||||||
cells: list[int] = []
|
cells: list[int] = []
|
||||||
ids: list[int] = []
|
ids: list[int] = []
|
||||||
|
families: list[int] = []
|
||||||
for el in frames:
|
for el in frames:
|
||||||
try:
|
try:
|
||||||
i = self._node_id_to_row[el.nodes[0]]
|
i = self._node_id_to_row[el.nodes[0]]
|
||||||
|
|
@ -400,6 +416,7 @@ class ModelRenderer:
|
||||||
continue
|
continue
|
||||||
cells.extend([2, i, j])
|
cells.extend([2, i, j])
|
||||||
ids.append(el.id)
|
ids.append(el.id)
|
||||||
|
families.append(element_family_index(el))
|
||||||
if not ids:
|
if not ids:
|
||||||
return
|
return
|
||||||
pd = pv.PolyData()
|
pd = pv.PolyData()
|
||||||
|
|
@ -407,16 +424,18 @@ class ModelRenderer:
|
||||||
pd.lines = np.array(cells, dtype=np.int64)
|
pd.lines = np.array(cells, dtype=np.int64)
|
||||||
pd.cell_data["_oss_id"] = np.array(ids, 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)
|
pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object)
|
||||||
pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8)
|
# Cell scalar carries the palette slot (family, or SELECTED_STATE).
|
||||||
|
pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8)
|
||||||
|
|
||||||
self._frame_pd = pd
|
self._frame_pd = pd
|
||||||
self._frame_ids_ordered = ids
|
self._frame_ids_ordered = ids
|
||||||
self._frame_id_to_row = {eid: i for i, eid in enumerate(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(
|
self._frame_actor = self._plotter.add_mesh(
|
||||||
pd,
|
pd,
|
||||||
scalars="_oss_state",
|
scalars="_oss_state",
|
||||||
cmap=self._FRAME_LUT,
|
cmap=family_palette(self._style),
|
||||||
clim=[0, 1],
|
clim=[0, SELECTED_STATE],
|
||||||
show_scalar_bar=False,
|
show_scalar_bar=False,
|
||||||
line_width=3.0,
|
line_width=3.0,
|
||||||
pickable=True,
|
pickable=True,
|
||||||
|
|
@ -761,8 +780,8 @@ class ModelRenderer:
|
||||||
"""Add one semi-transparent steel-blue non-pickable aux actor."""
|
"""Add one semi-transparent steel-blue non-pickable aux actor."""
|
||||||
actor = self._plotter.add_mesh(
|
actor = self._plotter.add_mesh(
|
||||||
mesh,
|
mesh,
|
||||||
color=(0.35, 0.60, 0.85), # cool steel-blue
|
color=self._style.extrusion_color,
|
||||||
opacity=0.22,
|
opacity=self._style.extrusion_opacity,
|
||||||
show_edges=True,
|
show_edges=True,
|
||||||
edge_color=(0.15, 0.25, 0.45),
|
edge_color=(0.15, 0.25, 0.45),
|
||||||
line_width=1.0,
|
line_width=1.0,
|
||||||
|
|
@ -982,7 +1001,7 @@ class ModelRenderer:
|
||||||
ndf = project.ndf
|
ndf = project.ndf
|
||||||
dof_idx = _dof_indices(ndf)
|
dof_idx = _dof_indices(ndf)
|
||||||
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6)
|
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6)
|
||||||
support_color = (1.0, 0.5, 0.1)
|
support_color = self._style.support_color
|
||||||
for node in project.nodes:
|
for node in project.nodes:
|
||||||
if not any(node.restraint[i] for i in dof_idx):
|
if not any(node.restraint[i] for i in dof_idx):
|
||||||
continue
|
continue
|
||||||
|
|
@ -996,7 +1015,7 @@ class ModelRenderer:
|
||||||
if not project.load_patterns or self._node_original_points is None:
|
if not project.load_patterns or self._node_original_points is None:
|
||||||
return
|
return
|
||||||
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6)
|
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6)
|
||||||
load_color = (0.2, 0.85, 0.2)
|
load_color = self._style.nodal_load_color
|
||||||
node_by_id = {n.id: n for n in project.nodes}
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
elem_by_id = {e.id: e for e in project.elements}
|
elem_by_id = {e.id: e for e in project.elements}
|
||||||
for pattern in project.load_patterns:
|
for pattern in project.load_patterns:
|
||||||
|
|
@ -1027,7 +1046,7 @@ class ModelRenderer:
|
||||||
# Draw N arrows along the element span, each perpendicular
|
# Draw N arrows along the element span, each perpendicular
|
||||||
# to the axis in the direction of the load. Uses the same
|
# to the axis in the direction of the load. Uses the same
|
||||||
# orange-green palette as nodal loads but with shorter arrows.
|
# orange-green palette as nodal loads but with shorter arrows.
|
||||||
elem_load_color = (1.0, 0.55, 0.2) # orange
|
elem_load_color = self._style.element_load_color # distributed loads
|
||||||
n_arrows_per_elem = 5
|
n_arrows_per_elem = 5
|
||||||
for eload in pattern.element_loads:
|
for eload in pattern.element_loads:
|
||||||
if not isinstance(eload, UniformElementLoad):
|
if not isinstance(eload, UniformElementLoad):
|
||||||
|
|
@ -1116,6 +1135,7 @@ class ModelRenderer:
|
||||||
self._node_ids_ordered = []
|
self._node_ids_ordered = []
|
||||||
self._node_id_to_row = {}
|
self._node_id_to_row = {}
|
||||||
self._frame_ids_ordered = []
|
self._frame_ids_ordered = []
|
||||||
|
self._frame_family = []
|
||||||
self._frame_id_to_row = {}
|
self._frame_id_to_row = {}
|
||||||
self._node_original_points = None
|
self._node_original_points = None
|
||||||
self._deformation = None
|
self._deformation = None
|
||||||
|
|
@ -1148,7 +1168,7 @@ class ModelRenderer:
|
||||||
return self._plotter.add_point_labels(
|
return self._plotter.add_point_labels(
|
||||||
pts,
|
pts,
|
||||||
labels,
|
labels,
|
||||||
font_size=12,
|
font_size=self._style.label_font_size,
|
||||||
shape_opacity=0.15,
|
shape_opacity=0.15,
|
||||||
text_color="black",
|
text_color="black",
|
||||||
point_color="white",
|
point_color="white",
|
||||||
|
|
@ -1178,7 +1198,7 @@ class ModelRenderer:
|
||||||
return self._plotter.add_point_labels(
|
return self._plotter.add_point_labels(
|
||||||
np.asarray(centers),
|
np.asarray(centers),
|
||||||
labels,
|
labels,
|
||||||
font_size=12,
|
font_size=self._style.label_font_size,
|
||||||
shape_opacity=0.15,
|
shape_opacity=0.15,
|
||||||
text_color="black",
|
text_color="black",
|
||||||
point_color="white",
|
point_color="white",
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,63 @@ All colors, sizes, and glyph parameters live here. Changes apply
|
||||||
globally through the renderer; downstream code never hard-codes a
|
globally through the renderer; downstream code never hard-codes a
|
||||||
color or radius. A future "theme" feature can simply swap a different
|
color or radius. A future "theme" feature can simply swap a different
|
||||||
RenderStyle instance.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -19,13 +71,13 @@ class RenderStyle:
|
||||||
background_top: str = "#dbe2ef"
|
background_top: str = "#dbe2ef"
|
||||||
background_bottom: str = "#f5f7fb"
|
background_bottom: str = "#f5f7fb"
|
||||||
|
|
||||||
node_color: str = "#f0a500" # warm gold
|
node_color: str = "#d9d9d9" # normal node sphere / marker
|
||||||
node_selected_color: str = "#00d4ff" # bright cyan
|
node_selected_color: str = "#00ffff" # selected node
|
||||||
|
|
||||||
frame_color: str = "#1f1f1f"
|
frame_color: str = "#1f1f1f"
|
||||||
truss_color: str = "#2e5cb8"
|
truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad
|
||||||
zerolength_color: str = "#a020f0"
|
zerolength_color: str = "#a020f0"
|
||||||
selected_color: str = "#00d4ff"
|
selected_color: str = "#00ffff" # selected element highlight
|
||||||
|
|
||||||
# ── element palette (adapted from opstool v1.0.26, GPL-3.0) ───────
|
# ── element palette (adapted from opstool v1.0.26, GPL-3.0) ───────
|
||||||
# opstool colours wireframe elements by *family* rather than painting
|
# opstool colours wireframe elements by *family* rather than painting
|
||||||
|
|
@ -36,6 +88,14 @@ class RenderStyle:
|
||||||
element_truss_color: str = "#FF8C00" # truss family
|
element_truss_color: str = "#FF8C00" # truss family
|
||||||
element_link_color: str = "#39FF14" # zero-length / link 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,
|
#: Diverging scale for scalar response overlays (force diagrams today,
|
||||||
#: nodal / element response plots later): blue → red, evenly spaced.
|
#: nodal / element response plots later): blue → red, evenly spaced.
|
||||||
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
|
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
|
||||||
|
|
@ -57,7 +117,7 @@ class RenderStyle:
|
||||||
roller_color: str = "#e67e22"
|
roller_color: str = "#e67e22"
|
||||||
custom_support_color: str = "#7f8c8d"
|
custom_support_color: str = "#7f8c8d"
|
||||||
|
|
||||||
load_color: str = "#27ae60"
|
load_color: str = "#27ae60" # Y-axis colour of the view triad
|
||||||
mass_color: str = "#9b59b6"
|
mass_color: str = "#9b59b6"
|
||||||
|
|
||||||
# ── sizes (relative to bbox diagonal unless absolute) ────────────
|
# ── sizes (relative to bbox diagonal unless absolute) ────────────
|
||||||
|
|
@ -75,6 +135,9 @@ class RenderStyle:
|
||||||
|
|
||||||
selection_thickness_factor: float = 1.6 # multiplier for selected actors
|
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 ──────────────────────────────────────────────────────
|
# ── helpers ──────────────────────────────────────────────────────
|
||||||
def response_colorscale(self) -> list[tuple[float, str]]:
|
def response_colorscale(self) -> list[tuple[float, str]]:
|
||||||
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
|
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
|
||||||
|
|
@ -84,3 +147,11 @@ class RenderStyle:
|
||||||
return [
|
return [
|
||||||
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors)
|
(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}
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,12 @@ class PlotlyCanvas(QWidget):
|
||||||
self._options = replace(self._options, show_local_axes=bool(enabled))
|
self._options = replace(self._options, show_local_axes=bool(enabled))
|
||||||
self.render()
|
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:
|
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||||
self._options = replace(
|
self._options = replace(
|
||||||
self._options,
|
self._options,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ from otko.core import (
|
||||||
ZeroLengthElement,
|
ZeroLengthElement,
|
||||||
ZeroLengthSectionElement,
|
ZeroLengthSectionElement,
|
||||||
)
|
)
|
||||||
from otko.views.canvas3d.style import RenderStyle
|
from otko.views.canvas3d.style import (
|
||||||
|
RenderStyle,
|
||||||
|
element_family_index,
|
||||||
|
family_palette,
|
||||||
|
)
|
||||||
|
|
||||||
_FRAME_CLASSES = (
|
_FRAME_CLASSES = (
|
||||||
ElasticBeamColumn,
|
ElasticBeamColumn,
|
||||||
|
|
@ -56,19 +60,6 @@ _FRAME_CLASSES = (
|
||||||
ZeroLengthElement,
|
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`.
|
#: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`.
|
||||||
_BOX_TRIS = (
|
_BOX_TRIS = (
|
||||||
(0, 1, 2),
|
(0, 1, 2),
|
||||||
|
|
@ -96,7 +87,6 @@ _SUPPORT_SYMBOLS = {
|
||||||
_NODE_MARKER_SIZE = 7.0
|
_NODE_MARKER_SIZE = 7.0
|
||||||
_SUPPORT_MARKER_SIZE = 11.0
|
_SUPPORT_MARKER_SIZE = 11.0
|
||||||
_SNAP_MARKER_SIZE = 8.0
|
_SNAP_MARKER_SIZE = 8.0
|
||||||
_LABEL_FONT_SIZE = 11
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -275,6 +265,10 @@ class PlotlyTraceBuilder:
|
||||||
def __init__(self, style: RenderStyle | None = None) -> None:
|
def __init__(self, style: RenderStyle | None = None) -> None:
|
||||||
self._style = style or RenderStyle()
|
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 ───────────────────────────────────────────────────────
|
# ── public ───────────────────────────────────────────────────────
|
||||||
def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene:
|
def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene:
|
||||||
opts = options or SceneOptions()
|
opts = options or SceneOptions()
|
||||||
|
|
@ -488,7 +482,12 @@ class PlotlyTraceBuilder:
|
||||||
) -> None:
|
) -> None:
|
||||||
if not len(points):
|
if not len(points):
|
||||||
return
|
return
|
||||||
colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids]
|
colors = [
|
||||||
|
self._style.node_selected_color
|
||||||
|
if nid in opts.selection_nodes
|
||||||
|
else self._style.node_color
|
||||||
|
for nid in node_ids
|
||||||
|
]
|
||||||
data.append(
|
data.append(
|
||||||
{
|
{
|
||||||
"type": "scatter3d",
|
"type": "scatter3d",
|
||||||
|
|
@ -528,12 +527,7 @@ class PlotlyTraceBuilder:
|
||||||
"""
|
"""
|
||||||
if not len(points):
|
if not len(points):
|
||||||
return
|
return
|
||||||
scale_colors = [
|
scale_colors = family_palette(self._style)
|
||||||
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
|
selected_index = len(scale_colors) - 1
|
||||||
|
|
||||||
x: list[float | None] = []
|
x: list[float | None] = []
|
||||||
|
|
@ -548,7 +542,9 @@ class PlotlyTraceBuilder:
|
||||||
j = node_row.get(el.nodes[1])
|
j = node_row.get(el.nodes[1])
|
||||||
if i is None or j is None:
|
if i is None or j is None:
|
||||||
continue
|
continue
|
||||||
index = float(selected_index if el.id in opts.selection_elements else _family_index(el))
|
index = float(
|
||||||
|
selected_index if el.id in opts.selection_elements else element_family_index(el)
|
||||||
|
)
|
||||||
x.extend([float(points[i][0]), float(points[j][0]), None])
|
x.extend([float(points[i][0]), float(points[j][0]), None])
|
||||||
y.extend([float(points[i][1]), float(points[j][1]), None])
|
y.extend([float(points[i][1]), float(points[j][1]), None])
|
||||||
z.extend([float(points[i][2]), float(points[j][2]), None])
|
z.extend([float(points[i][2]), float(points[j][2]), None])
|
||||||
|
|
@ -603,7 +599,7 @@ class PlotlyTraceBuilder:
|
||||||
"y": [float(n.coords[1]) for n in nodes],
|
"y": [float(n.coords[1]) for n in nodes],
|
||||||
"z": [float(n.coords[2]) for n in nodes],
|
"z": [float(n.coords[2]) for n in nodes],
|
||||||
"marker": {
|
"marker": {
|
||||||
"color": "#ff8019",
|
"color": self._style.support_color,
|
||||||
"size": _SUPPORT_MARKER_SIZE,
|
"size": _SUPPORT_MARKER_SIZE,
|
||||||
"symbol": _SUPPORT_SYMBOLS[kind],
|
"symbol": _SUPPORT_SYMBOLS[kind],
|
||||||
"line": {"color": "#7f3f00", "width": 1},
|
"line": {"color": "#7f3f00", "width": 1},
|
||||||
|
|
@ -708,7 +704,7 @@ class PlotlyTraceBuilder:
|
||||||
nodal_u,
|
nodal_u,
|
||||||
nodal_v,
|
nodal_v,
|
||||||
nodal_w,
|
nodal_w,
|
||||||
color="#33d933",
|
color=self._style.nodal_load_color,
|
||||||
name="nodal-loads",
|
name="nodal-loads",
|
||||||
size=scale,
|
size=scale,
|
||||||
)
|
)
|
||||||
|
|
@ -722,7 +718,7 @@ class PlotlyTraceBuilder:
|
||||||
dist_u,
|
dist_u,
|
||||||
dist_v,
|
dist_v,
|
||||||
dist_w,
|
dist_w,
|
||||||
color="#ff8c33",
|
color=self._style.element_load_color,
|
||||||
name="element-loads",
|
name="element-loads",
|
||||||
size=0.6 * scale,
|
size=0.6 * scale,
|
||||||
)
|
)
|
||||||
|
|
@ -833,7 +829,13 @@ class PlotlyTraceBuilder:
|
||||||
y_local, z_local = _frame_basis(el, x_local)
|
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))
|
mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z))
|
||||||
if not mesh.is_empty:
|
if not mesh.is_empty:
|
||||||
data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions"))
|
data.append(
|
||||||
|
mesh.as_trace(
|
||||||
|
color=self._style.extrusion_color,
|
||||||
|
opacity=self._style.extrusion_opacity,
|
||||||
|
name="extrusions",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _box_corners(
|
def _box_corners(
|
||||||
|
|
@ -872,7 +874,11 @@ class PlotlyTraceBuilder:
|
||||||
) -> None:
|
) -> None:
|
||||||
if opts.show_node_labels and len(points):
|
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]
|
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"))
|
data.append(
|
||||||
|
_text_trace(
|
||||||
|
points, labels, name="node-labels", font_size=self._style.label_font_size
|
||||||
|
)
|
||||||
|
)
|
||||||
if opts.show_element_labels and len(points):
|
if opts.show_element_labels and len(points):
|
||||||
centers: list[np.ndarray] = []
|
centers: list[np.ndarray] = []
|
||||||
labels: list[str] = []
|
labels: list[str] = []
|
||||||
|
|
@ -886,7 +892,14 @@ class PlotlyTraceBuilder:
|
||||||
centers.append((points[i] + points[j]) / 2.0)
|
centers.append((points[i] + points[j]) / 2.0)
|
||||||
labels.append(el.name.strip() if el.name.strip() else f"E{el.id}")
|
labels.append(el.name.strip() if el.name.strip() else f"E{el.id}")
|
||||||
if centers:
|
if centers:
|
||||||
data.append(_text_trace(np.asarray(centers), labels, name="element-labels"))
|
data.append(
|
||||||
|
_text_trace(
|
||||||
|
np.asarray(centers),
|
||||||
|
labels,
|
||||||
|
name="element-labels",
|
||||||
|
font_size=self._style.label_font_size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_hover_marker(data: list[dict[str, Any]]) -> int:
|
def _build_hover_marker(data: list[dict[str, Any]]) -> int:
|
||||||
|
|
@ -942,7 +955,9 @@ def _cone_trace(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]:
|
def _text_trace(
|
||||||
|
points: np.ndarray, labels: list[str], *, name: str, font_size: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"type": "scatter3d",
|
"type": "scatter3d",
|
||||||
"mode": "text",
|
"mode": "text",
|
||||||
|
|
@ -951,7 +966,7 @@ def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str
|
||||||
"z": [float(p[2]) for p in points],
|
"z": [float(p[2]) for p in points],
|
||||||
"text": labels,
|
"text": labels,
|
||||||
"textposition": "top center",
|
"textposition": "top center",
|
||||||
"textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"},
|
"textfont": {"size": font_size, "color": "#111111"},
|
||||||
"hoverinfo": "skip",
|
"hoverinfo": "skip",
|
||||||
"name": name,
|
"name": name,
|
||||||
"showlegend": False,
|
"showlegend": False,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from otko.views.dialogs.move import MoveDialog
|
||||||
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
||||||
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
||||||
from otko.views.dialogs.plain_pattern import PlainPatternDialog
|
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_grid_lines import QuickGridLinesDialog
|
||||||
from otko.views.dialogs.quick_guide import QuickGuideDialog
|
from otko.views.dialogs.quick_guide import QuickGuideDialog
|
||||||
from otko.views.dialogs.replicate import ReplicateDialog
|
from otko.views.dialogs.replicate import ReplicateDialog
|
||||||
|
|
@ -69,6 +70,7 @@ __all__ = [
|
||||||
"PathTimeSeriesDialog",
|
"PathTimeSeriesDialog",
|
||||||
"PatternLoadsDialog",
|
"PatternLoadsDialog",
|
||||||
"PlainPatternDialog",
|
"PlainPatternDialog",
|
||||||
|
"PlotPropertiesDialog",
|
||||||
"QuickGridLinesDialog",
|
"QuickGridLinesDialog",
|
||||||
"QuickGuideDialog",
|
"QuickGuideDialog",
|
||||||
"ReplicateDialog",
|
"ReplicateDialog",
|
||||||
|
|
|
||||||
139
src/otko/views/dialogs/plot_properties.py
Normal file
139
src/otko/views/dialogs/plot_properties.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""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()
|
||||||
|
|
@ -13,6 +13,7 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ from otko.views.action_handlers import ActionHandlers
|
||||||
from otko.views.canvas3d import ModelCanvas
|
from otko.views.canvas3d import ModelCanvas
|
||||||
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
||||||
from otko.views.canvas3d.selection import SelectionState
|
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.canvas_base import plotly_available
|
||||||
from otko.views.dock_manager import DockManager
|
from otko.views.dock_manager import DockManager
|
||||||
from otko.views.menu_builder import MenuBuilder
|
from otko.views.menu_builder import MenuBuilder
|
||||||
|
|
@ -72,6 +74,8 @@ class MainWindow(
|
||||||
|
|
||||||
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
||||||
self._canvas_backend = self._stored_canvas_backend()
|
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._canvases: dict[str, Any] = {}
|
||||||
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
||||||
# A stack holds every backend's canvas: swapping is just
|
# A stack holds every backend's canvas: swapping is just
|
||||||
|
|
@ -115,9 +119,9 @@ class MainWindow(
|
||||||
# Imported lazily so a plotly-less install still boots PyVista.
|
# Imported lazily so a plotly-less install still boots PyVista.
|
||||||
from otko.views.canvas_plotly import PlotlyCanvas
|
from otko.views.canvas_plotly import PlotlyCanvas
|
||||||
|
|
||||||
canvas = PlotlyCanvas(self, selection=self._selection)
|
canvas = PlotlyCanvas(self, style=self._style, selection=self._selection)
|
||||||
else:
|
else:
|
||||||
canvas = ModelCanvas(self, selection=self._selection)
|
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
|
||||||
self._canvases[backend] = canvas
|
self._canvases[backend] = canvas
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
|
|
@ -145,6 +149,35 @@ class MainWindow(
|
||||||
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
||||||
return self._canvas_backend
|
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:
|
def _build_tooling(self) -> None:
|
||||||
"""(Re)build the tool controller and tools against the current canvas."""
|
"""(Re)build the tool controller and tools against the current canvas."""
|
||||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
||||||
|
|
@ -261,6 +294,7 @@ class MainWindow(
|
||||||
self._act_about.triggered.connect(self._on_about)
|
self._act_about.triggered.connect(self._on_about)
|
||||||
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
||||||
self._act_set_units.triggered.connect(self._on_set_units)
|
self._act_set_units.triggered.connect(self._on_set_units)
|
||||||
|
self._act_plot_properties.triggered.connect(self._on_plot_properties)
|
||||||
|
|
||||||
# Edit
|
# Edit
|
||||||
self._act_delete.triggered.connect(self._on_delete)
|
self._act_delete.triggered.connect(self._on_delete)
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,10 @@ class MenuBuilder:
|
||||||
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
||||||
self._act_quick_guide = QAction("&Quick Guide", self)
|
self._act_quick_guide = QAction("&Quick Guide", self)
|
||||||
self._act_set_units = QAction("Set Display &Units…", 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.
|
# Canvas backend — exclusive radio pair live-switching the 3D view.
|
||||||
from otko.views.canvas_base import plotly_available
|
from otko.views.canvas_base import plotly_available
|
||||||
|
|
@ -512,6 +516,7 @@ class MenuBuilder:
|
||||||
|
|
||||||
m_options = mb.addMenu("&Options")
|
m_options = mb.addMenu("&Options")
|
||||||
m_options.addAction(self._act_set_units)
|
m_options.addAction(self._act_set_units)
|
||||||
|
m_options.addAction(self._act_plot_properties)
|
||||||
m_backend = m_options.addMenu("Canvas &Backend")
|
m_backend = m_options.addMenu("Canvas &Backend")
|
||||||
m_backend.addAction(self._act_canvas_pyvista)
|
m_backend.addAction(self._act_canvas_pyvista)
|
||||||
m_backend.addAction(self._act_canvas_plotly)
|
m_backend.addAction(self._act_canvas_plotly)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,23 @@ class RenderControls:
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── SAP2000-style view switching + working level ───────────────
|
# ── 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:
|
def _on_toggle_parallel(self, on: bool) -> None:
|
||||||
cam = self._canvas.camera
|
cam = self._canvas.camera
|
||||||
cam.parallel_projection = on
|
cam.parallel_projection = on
|
||||||
|
|
|
||||||
131
tests/gui/test_plot_properties.py
Normal file
131
tests/gui/test_plot_properties.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""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()
|
||||||
117
tests/unit/test_render_style.py
Normal file
117
tests/unit/test_render_style.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""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]
|
||||||
|
|
@ -24,6 +24,7 @@ from otko.views.canvas3d.model_renderer import (
|
||||||
RendererMode,
|
RendererMode,
|
||||||
_classify_support,
|
_classify_support,
|
||||||
)
|
)
|
||||||
|
from otko.views.canvas3d.style import SELECTED_STATE
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── support classification ────────────────────────────
|
# ──────────────────────────── support classification ────────────────────────────
|
||||||
|
|
@ -125,9 +126,13 @@ def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project
|
||||||
frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist()
|
frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist()
|
||||||
# Nodes 1 and 3 selected → row 0 and row 2
|
# Nodes 1 and 3 selected → row 0 and row 2
|
||||||
assert node_states == [1, 0, 1]
|
assert node_states == [1, 0, 1]
|
||||||
# Element 2 selected → it's the second frame (index 1 in frame_ids_ordered)
|
# Frame cells carry the palette slot: the element family normally, and
|
||||||
|
# SELECTED_STATE when selected.
|
||||||
selected_frame_idx = r._frame_id_to_row[2]
|
selected_frame_idx = r._frame_id_to_row[2]
|
||||||
assert frame_states[selected_frame_idx] == 1
|
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
|
||||||
|
|
||||||
|
|
||||||
def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue